Fix Vim escape blocke

This commit is contained in:
Gregory Schier committed 2019-04-18 11:34:48 -07:00
commit 7f17494520
259 files changed
+36009 -11152

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ test: off
shallow_clone: true
platform: x64
environment:
NODEJS_VERSION: "10"
NODEJS_VERSION: '10'
CSC_LINK: '%WINDOWS_CSC_LINK%'
CSC_KEY_PASSWORD: '%WINDOWS_CSC_KEY_PASSWORD%'
+2 -2
View File
@@ -1,7 +1,7 @@
[*.{js,json,html,md}]
[*.{js,json,html,md,graphql,gql}]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
end_of_line = lf
+5 -1
View File
@@ -23,4 +23,8 @@ node_modules/
.idea
.DS_Store
*test-plugins
.vscode
.vscode
graphql.config.json
.cache
.graphqlconfig
schema.graphql
+1 -1
View File
@@ -1 +1 @@
10
10.15
+8 -7
View File
@@ -1,4 +1,4 @@
# Insomnia REST Client
# Insomnia REST Client
[![Insomnia](https://img.shields.io/badge/maintainer-Insomnia-purple.svg?colorB=6e60cc)](https://insomnia.rest)
[![Travis](https://api.travis-ci.org/getinsomnia/insomnia.svg)](https://travis-ci.org/getinsomnia/insomnia)
@@ -12,26 +12,26 @@ Insomnia is a cross-platform _REST client_, built on top of [Electron](http://el
## Download
Insomnia is available for Mac, Windows, and Linux and can be downloaded
Insomnia is available for Mac, Windows, and Linux and can be downloaded
from the website.
**[https://insomnia.rest/download](https://insomnia.rest/download/)**
## Bugs and Feature Requests
Have a bug or a feature request? First, read the
[issue guidelines](CONTRIBUTING.md#using-the-issue-tracker) and search for existing and
Have a bug or a feature request? First, read the
[issue guidelines](CONTRIBUTING.md#using-the-issue-tracker) and search for existing and
closed issues. If your problem or idea is not addressed yet, [please open a new issue](/issues).
For more generic product questions and feedback, join the [Slack Team](https://chat.insomnia.rest) or email
For more generic product questions and feedback, join the [Slack Team](https://chat.insomnia.rest) or email
[support@insomnia.rest](mailto:support@insomnia.rest)
## Contributing
Please read through our [contributing guidelines](CONTRIBUTING.md) and [code of conduct](CODE_OF_CONDUCT.md). Included are directions
Please read through our [contributing guidelines](CONTRIBUTING.md) and [code of conduct](CODE_OF_CONDUCT.md). Included are directions
for opening issues, coding standards, and notes on development.
Editor preferences are available in the [editor config](.editorconfig) for easy use in
Editor preferences are available in the [editor config](.editorconfig) for easy use in
common text editors. Read more and download plugins at [editorconfig.org](http://editorconfig.org).
## Developing
@@ -94,6 +94,7 @@ Here is a list of plugins available for installation via NPM.
- [XDebug](https://www.npmjs.com/package/insomnia-plugin-xdebug) – Enable Xdebug debugging by adding an `XDEBUG_SESSION` cookie to the request
## Community Projects
- [GitHub API Spec Importer](https://github.com/swinton/github-rest-apis-for-insomnia) – A complete set of GitHub REST API route specifications that can be imported straight into Insomnia
- [Swaggymnia](https://github.com/mlabouardy/swaggymnia) – Generate [Swagger](https://swagger.io/) documentation for your existing API in Insomnia.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"lerna": "3.4.3",
"lerna": "3.13.1",
"version": "independent",
"packages": ["plugins/*", "packages/*"]
}
+9311 -1729
View File
File diff suppressed because it is too large. Load diff
+22 -11
View File
@@ -8,13 +8,13 @@
"url": "https://github.com/getinsomnia/insomnia"
},
"scripts": {
"bootstrap": "npm install && lerna bootstrap && lerna run bootstrap",
"bootstrap": "npm install && lerna bootstrap && lerna run --parallel --stream bootstrap",
"publish": "lerna publish",
"clean": "lerna clean --yes && rimraf node_modules",
"test": "lerna run test --stream",
"app-start": "lerna run start --stream --scope=insomnia-app",
"app-build": "lerna run build --stream --scope=insomnia-app",
"app-package": "lerna run package --stream --scope=insomnia-app",
"test": "lerna run --stream --parallel test",
"app-start": "lerna run start --stream --parallel --bail",
"app-build": "lerna run build --stream --parallel --bail",
"app-package": "lerna run package --stream --parallel --bail",
"format-code": "prettier --write \"**/*.js\""
},
"husky": {
@@ -23,11 +23,22 @@
}
},
"devDependencies": {
"@babel/cli": "^7.4.3",
"@babel/core": "^7.4.3",
"@babel/plugin-proposal-decorators": "^7.4.0",
"@babel/preset-env": "^7.4.3",
"@babel/preset-flow": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"babel-eslint": "^8.2.3",
"babel-jest": "^22.4.3",
"babel-loader": "^8.0.5",
"flow-bin": "^0.96.0",
"husky": "^1.0.0-rc.9",
"jest": "^23.6.0",
"lerna": "^3.4.3",
"prettier": "^1.13.6",
"pretty-quick": "^1.6.0",
"rimraf": "^2.6.2"
}
"jest": "^24.7.1",
"lerna": "^3.13.1",
"prettier": "^1.16.4",
"pretty-quick": "^1.10.0",
"rimraf": "^2.6.3"
},
"dependencies": {}
}
+16 -9
View File
@@ -1,11 +1,16 @@
{
"presets": [
"react",
"flow"
"@babel/preset-react",
"@babel/preset-flow"
],
"plugins": [
"transform-object-rest-spread",
"transform-decorators-legacy"
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
["@babel/plugin-proposal-class-properties", { "loose" : true }]
],
"env": {
"development": {
@@ -16,13 +21,15 @@
"test": {
"presets": [
[
"env", {
"targets": {
"node": "8.4"
"@babel/preset-env",
{
"targets": {
"node": "10"
}
}
}
],
"flow" // We need to add this again because it has to run before es2015
// We need to add this again because it has to run before es2015
"@babel/preset-flow"
]
}
}
+2
View File
@@ -36,8 +36,10 @@
"indent": "off",
"no-var": "error",
"no-duplicate-imports": "off",
"flowtype/space-after-type-colon": "off",
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"space-in-parens": "off",
"space-before-function-paren": [
"error",
{
@@ -1,7 +1,10 @@
import * as db from '../common/database';
import * as models from '../models';
import * as fetch from '../account/fetch';
export async function globalBeforeEach() {
// Setup the local database in case it's used
fetch.setup('insomnia-tests', 'http://localhost:8000');
await db.init(models.types(), { inMemoryOnly: true }, true);
}
@@ -9,14 +9,10 @@ module.exports = {
jsbn: forge.jsbn,
util: forge.util,
pkcs5: {
pbkdf2() {},
pbkdf2: forge.pkcs5.pbkdf2,
},
md: {
sha256: {
create() {
return 'TODO';
},
},
sha256: forge.md.sha256,
},
rsa: {
setPublicKey() {
@@ -0,0 +1,102 @@
import * as crypt from '../crypt';
describe('crypt', () => {
describe('deriveKey()', () => {
it('derives a key properly', async () => {
const result = await crypt.deriveKey('Password', 'email', 'salt');
const expected = 'fb058595c02ae9660ed7098273bf50e49407942ecc437bf317638d76c4578eae';
expect(result).toBe(expected);
});
});
describe('AES', () => {
it('encrypts and decrypts', () => {
const key = {
kty: 'oct',
alg: 'A256GCM',
ext: true,
key_ops: ['encrypt', 'decrypt'],
k: '5hs1f2xuiNPHUp11i6SWlsqYpWe_hWPcEKucZlwBfFE',
};
const resultEncrypted = crypt.encryptAES(key, 'Hello World!', 'additional data');
const resultDecrypted = crypt.decryptAES(key, resultEncrypted);
expect(resultDecrypted).toEqual('Hello World!');
});
});
describe('AES Buffer', () => {
it('encrypts and decrypts', () => {
const key = {
kty: 'oct',
alg: 'A256GCM',
ext: true,
key_ops: ['encrypt', 'decrypt'],
k: '5hs1f2xuiNPHUp11i6SWlsqYpWe_hWPcEKucZlwBfFE',
};
const resultEncrypted = crypt.encryptAESBuffer(key, Buffer.from('Hello World!', 'utf8'));
const resultDecrypted = crypt.decryptAESToBuffer(key, resultEncrypted);
expect(resultDecrypted).toEqual(Buffer.from('Hello World!', 'utf8'));
});
});
describe('RSA', () => {
it('encrypts and decrypts', () => {
const privateKey = {
alg: 'RSA-OAEP-256',
kty: 'RSA',
key_ops: ['decrypt'],
ext: true,
d:
'UkouuQID2o9Q6VyiRMmK8ETPsAHWEL2HMYwy34c4nTpM7KfqlNeMzs6HmbuEfx-bwUvTqOO4Tz7FZw4ILD6s5sE9' +
'xqIxmV-fIiqiBI4aWKozxgf9OJZWKqru3loSd923O3fI3oa9ZCTaKc1U0bYOB-XP2Q_hB2M64Hb63-McXAM0RQwN' +
'R5vh_TweqcaBiXAyhuYl2NarOwrbSlSttkhZzy4i-otulPGkW61I5rNflsSmnYEijmD7zl9EouEOYcHlJGmNLHjG' +
'nHlb-avvwvER5NZVDwd6vT61QR1wwpnYSjVH_Z_OrqJu8U2J64J_MaZPJog3KbPYqZnGcxJ9ldnSYQ',
dp:
'SnO3kTAogveLWkqSuDVxQLOo4QXEq-Us_lM00dfGIuZrfWnyqOd6_NJKu-G62PCQgUMBy7r_f3N3sOseRVl_5fy3' +
'kd21n7WmnQcMGBm-VRathT0nOz-fhzbwCJwtuI1g36YtaCQEQiC4pYxMYmXaX1WGPhL4rGSNz5SMHrPvyTU',
dq:
'eaK2-w2Jb7rWWYhLon-RKlzWXTPHjnt1JfLkD_D12FNE6KAcbyIETDid8_sXnqj3oCr3HrO-AuNI87zZJ4UVsy2J' +
'mvNNVFTn0s8R6TZ9_o9LCXBdBcRST5qpAax0t-duHRaKMuWPG3xAb2Uub6T42C7yd-mpOIRo7uSHFGQGzWk',
e: 'AQAB',
n:
'5wOecCVWaDB2s-ybsCp1BskBW5fj3iH4xja5hNTfW_s-ERgbEoBA_PSF60PC1se4r_oWwFBfIRL9OUTYwYOuQOuC' +
'rgd7ODa_YBeOjzHUA1b0K5kWXaqPxkmN8kJPISyLdLjCCOwHtTFnqxL9JjnU4aIxy4OU1S5KR6v-XVeLOZtUOm4k' +
'AhLVfmdzYB1nZmq9xH1O8_acIUoWDbAWX6fIXhPhn7jCuCO4WZQVDxZ5_bc27UVhR4VYe2Our7aESUQ5ZyMtYNym' +
'o9Oy0y_m3OS6W_JR_feXBbxRCBuGf7fjnvV9ohx1ZqLpJFx9_xL7naoVCQhBDfVE31iYz3L6KTIhFQ',
p:
'_Pyx-puBM_BQubDg8BZsrssgmECHuieKEwlR19fckczS4dlgQVUemUTr-RqmItj7k-WMG7mWuRgbIrGO1sigpuDy' +
'uKWkg3KyqoeIvgsaJV05xu8pneVblTXJSCtNMXvYMa6mPNYudUSy8-TlLyLg_w5lUnuA8Mq8xehzCsPrM-0',
q:
'6cPuxuW0zaMIVAJpOcLf5D0dA-mLXxmmJoMDodamM7t7_oGYgFR3Os9gtB5TYxsUjjRCWGkDy7ru8arcbTNCnNFd' +
'PAayzTdWCW-GBs3xswggjmugGupjOnrtD-N1_fG040Ka8UqwMyI1lUapxaKhViR9TNYUz6EAOjxycf8MTMk',
qi:
'FMVx12_ioueu052xgFxWdIS_lImUGTrw8Iiw_kp-KKsONtofs91A5GVRtyg_wdXpG2qyomaet1hTlHhLnoI23L2a' +
'EkQ87SokIpoR9lR8jfIRwLwKKXMc33_bRRQXvWop0yvTzmSGaC0gULcqj0OHiUR1u9Ver1ZvgGz2jh4mP_E',
};
const publicKey = {
alg: 'RSA-OAEP-256',
kty: 'RSA',
key_ops: ['encrypt'],
e: 'AQAB',
n:
'5wOecCVWaDB2s-ybsCp1BskBW5fj3iH4xja5hNTfW_s-ERgbEoBA_PSF60PC1se4r_oWwFBfIRL9OUTYwYOuQO' +
'uCrgd7ODa_YBeOjzHUA1b0K5kWXaqPxkmN8kJPISyLdLjCCOwHtTFnqxL9JjnU4aIxy4OU1S5KR6v-XVeLOZtU' +
'Om4kAhLVfmdzYB1nZmq9xH1O8_acIUoWDbAWX6fIXhPhn7jCuCO4WZQVDxZ5_bc27UVhR4VYe2Our7aESUQ5Zy' +
'MtYNymo9Oy0y_m3OS6W_JR_feXBbxRCBuGf7fjnvV9ohx1ZqLpJFx9_xL7naoVCQhBDfVE31iYz3L6KTIhFQ',
};
const resultEncrypted = crypt.encryptRSAWithJWK(publicKey, 'aaaaaaaaa');
const resultDecrypted = crypt.decryptRSAWithJWK(privateKey, resultEncrypted);
const expectedDecrypted = 'aaaaaaaaa';
expect(resultDecrypted.toString()).toEqual(expectedDecrypted);
});
});
});
@@ -1,6 +1,6 @@
import HKDF from 'hkdf';
import srp from 'srp-js';
import * as forge from 'node-forge';
import forge from 'node-forge';
const DEFAULT_BYTE_LENGTH = 32;
const DEFAULT_PBKDF2_ITERATIONS = 1e5; // 100,000
@@ -64,9 +64,32 @@ export function decryptRSAWithJWK(privateJWK, encryptedBlob) {
return decodeURIComponent(decrypted);
}
export function recryptRSAWithJWK(privateJWK, publicJWK, encryptedBlob) {
const decrypted = decryptRSAWithJWK(privateJWK, encryptedBlob);
return encryptRSAWithJWK(publicJWK, decrypted);
/**
* Encrypt data using symmetric key
*
* @param jwkOrKey JWK or string representing symmetric key
* @param buff data to encrypt
* @param additionalData any additional public data to attach
* @returns {{iv, t, d, ad}}
*/
export function encryptAESBuffer(jwkOrKey, buff, additionalData = '') {
// TODO: Add assertion checks for JWK
const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k);
const key = forge.util.hexToBytes(rawKey);
const iv = forge.random.getBytesSync(12);
const cipher = forge.cipher.createCipher('AES-GCM', key);
cipher.start({ additionalData, iv, tagLength: 128 });
cipher.update(forge.util.createBuffer(buff));
cipher.finish();
return {
iv: forge.util.bytesToHex(iv),
t: forge.util.bytesToHex(cipher.mode.tag),
ad: forge.util.bytesToHex(additionalData),
d: forge.util.bytesToHex(cipher.output),
};
}
/**
@@ -95,8 +118,8 @@ export function encryptAES(jwkOrKey, plaintext, additionalData = '') {
return {
iv: forge.util.bytesToHex(iv),
t: forge.util.bytesToHex(cipher.mode.tag),
d: forge.util.bytesToHex(cipher.output),
ad: forge.util.bytesToHex(additionalData),
d: forge.util.bytesToHex(cipher.output),
};
}
@@ -104,10 +127,10 @@ export function encryptAES(jwkOrKey, plaintext, additionalData = '') {
* Decrypt AES using a key
*
* @param jwkOrKey JWK or string representing symmetric key
* @param message
* @param encryptedResult encryption data
* @returns String
*/
export function decryptAES(jwkOrKey, message) {
export function decryptAES(jwkOrKey, encryptedResult) {
// TODO: Add assertion checks for JWK
const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k);
const key = forge.util.hexToBytes(rawKey);
@@ -118,13 +141,13 @@ export function decryptAES(jwkOrKey, message) {
const decipher = forge.cipher.createDecipher('AES-GCM', key);
decipher.start({
iv: forge.util.hexToBytes(message.iv),
tagLength: message.t.length * 4,
tag: forge.util.hexToBytes(message.t),
additionalData: forge.util.hexToBytes(message.ad),
iv: forge.util.hexToBytes(encryptedResult.iv),
tagLength: encryptedResult.t.length * 4,
tag: forge.util.hexToBytes(encryptedResult.t),
additionalData: forge.util.hexToBytes(encryptedResult.ad),
});
decipher.update(forge.util.createBuffer(forge.util.hexToBytes(message.d)));
decipher.update(forge.util.createBuffer(forge.util.hexToBytes(encryptedResult.d)));
if (decipher.finish()) {
return decodeURIComponent(decipher.output.toString());
@@ -134,21 +157,35 @@ export function decryptAES(jwkOrKey, message) {
}
/**
* Generate a random salt in hex
*
* Decrypts AES using a key to buffer
* @param jwkOrKey
* @param encryptedResult
* @returns {string}
*/
export function getRandomHex(bytes = DEFAULT_BYTE_LENGTH) {
return forge.util.bytesToHex(forge.random.getBytesSync(bytes));
}
export function decryptAESToBuffer(jwkOrKey, encryptedResult) {
// TODO: Add assertion checks for JWK
const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k);
const key = forge.util.hexToBytes(rawKey);
/**
* Generate a random account Id
*
* @returns {string}
*/
export function generateAccountId() {
return `act_${getRandomHex(DEFAULT_BYTE_LENGTH)}`;
// ~~~~~~~~~~~~~~~~~~~~ //
// Decrypt with AES-GCM //
// ~~~~~~~~~~~~~~~~~~~~ //
const decipher = forge.cipher.createDecipher('AES-GCM', key);
decipher.start({
iv: forge.util.hexToBytes(encryptedResult.iv),
tagLength: encryptedResult.t.length * 4,
tag: forge.util.hexToBytes(encryptedResult.t),
additionalData: forge.util.hexToBytes(encryptedResult.ad),
});
decipher.update(forge.util.createBuffer(forge.util.hexToBytes(encryptedResult.d)));
if (decipher.finish()) {
return Buffer.from(forge.util.bytesToHex(decipher.output), 'hex');
} else {
throw new Error('Failed to decrypt data');
}
}
/**
@@ -264,7 +301,7 @@ export async function generateKeyPairJWK() {
* @param rawEmail
* @returns {Promise}
*/
function _hkdfSalt(rawSalt, rawEmail) {
async function _hkdfSalt(rawSalt, rawEmail) {
return new Promise(resolve => {
const hkdf = new HKDF('sha256', rawSalt, rawEmail);
hkdf.derive('', DEFAULT_BYTE_LENGTH, buffer => resolve(buffer.toString('hex')));
+104
View File
@@ -0,0 +1,104 @@
import { delay } from '../common/misc';
import { parse as urlParse } from 'url';
import zlib from 'zlib';
let _userAgent = '';
let _baseUrl = '';
let _commandListeners = [];
export function setup(userAgent, baseUrl) {
_userAgent = userAgent;
_baseUrl = baseUrl;
}
export function onCommand(callback) {
_commandListeners.push(callback);
}
export async function post(path, obj, sessionId, compressBody = false) {
return _fetch('POST', path, obj, sessionId, compressBody);
}
export async function put(path, obj, sessionId, compressBody = false) {
return _fetch('PUT', path, obj, sessionId, compressBody);
}
export async function get(path, sessionId) {
return _fetch('GET', path, null, sessionId);
}
async function _fetch(method, path, obj, sessionId, compressBody = false, retries = 0) {
if (sessionId === undefined) {
throw new Error(`No session ID provided to ${method}:${path}`);
}
const config = {
method: method,
headers: {},
};
// Set some client information
if (_userAgent) {
config.headers['X-Insomnia-Client'] = _userAgent;
}
if (obj && compressBody) {
config.body = zlib.gzipSync(JSON.stringify(obj));
config.headers['Content-Type'] = 'application/json';
config.headers['Content-Encoding'] = 'gzip';
} else if (obj) {
config.body = JSON.stringify(obj);
config.headers['Content-Type'] = 'application/json';
}
if (sessionId) {
config.headers['X-Session-Id'] = sessionId;
}
let response;
const url = _getUrl(path);
try {
response = await window.fetch(url, config);
// Exponential backoff for 502 errors
if (response.status === 502 && retries < 5) {
retries++;
await delay(retries * 200);
return this._fetch(method, path, obj, sessionId, compressBody, retries);
}
} catch (err) {
throw new Error(`Failed to fetch '${url}'`);
}
const uri = response.headers.get('x-insomnia-command');
uri && _notifyCommandListeners(uri);
if (!response.ok) {
const err = new Error(`Response ${response.status} for ${path}`);
err.message = await response.text();
err.statusCode = response.status;
throw err;
}
if (response.headers.get('content-type') === 'application/json' || path.match(/\.json$/)) {
return response.json();
} else {
return response.text();
}
}
function _getUrl(path) {
if (!_baseUrl) {
throw new Error('API base URL not configured!');
}
return `${_baseUrl}${path}`;
}
function _notifyCommandListeners(uri) {
const parsed = urlParse(uri, true);
const command = `${parsed.hostname}${parsed.pathname}`;
const args = JSON.parse(JSON.stringify(parsed.query));
_commandListeners.map(fn => fn(command, args));
}
@@ -1,6 +1,22 @@
import srp from 'srp-js';
import * as srp from 'srp-js';
import * as crypt from './crypt';
import * as util from '../common/fetch';
import * as fetch from './fetch';
const loginCallbacks = [];
function _callCallbacks() {
const loggedIn = isLoggedIn();
console.log('[session] Sync state changed loggedIn=' + loggedIn);
for (const cb of loginCallbacks) {
if (typeof cb === 'function') {
cb(loggedIn);
}
}
}
export function onLoginLogout(callback) {
loginCallbacks.push(callback);
}
/** Create a new session for the user */
export async function login(rawEmail, rawPassphrase) {
@@ -15,7 +31,7 @@ export async function login(rawEmail, rawPassphrase) {
// Fetch Salt and Submit A To Server //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //
const { saltKey, saltAuth } = await util.post('/auth/login-s', { email });
const { saltKey, saltAuth } = await fetch.post('/auth/login-s', { email }, null);
const authSecret = await crypt.deriveKey(passphrase, email, saltKey);
const secret1 = await crypt.srpGenKey();
const c = new srp.Client(
@@ -26,10 +42,14 @@ export async function login(rawEmail, rawPassphrase) {
Buffer.from(secret1, 'hex'),
);
const srpA = c.computeA().toString('hex');
const { sessionStarterId, srpB } = await util.post('/auth/login-a', {
srpA,
email,
});
const { sessionStarterId, srpB } = await fetch.post(
'/auth/login-a',
{
srpA,
email,
},
null,
);
// ~~~~~~~~~~~~~~~~~~~~~ //
// Compute and Submit M1 //
@@ -37,10 +57,14 @@ export async function login(rawEmail, rawPassphrase) {
c.setB(Buffer.from(srpB, 'hex'));
const srpM1 = c.computeM1().toString('hex');
const { srpM2 } = await util.post('/auth/login-m1', {
srpM1,
sessionStarterId,
});
const { srpM2 } = await fetch.post(
'/auth/login-m1',
{
srpM1,
sessionStarterId,
},
null,
);
// ~~~~~~~~~~~~~~~~~~~~~~~~~ //
// Verify Server Identity M2 //
@@ -64,7 +88,7 @@ export async function login(rawEmail, rawPassphrase) {
accountId,
firstName,
lastName,
} = await whoami(sessionId);
} = await _whoami(sessionId);
const derivedSymmetricKey = await crypt.deriveKey(passphrase, email, saltEnc);
const symmetricKeyStr = await crypt.decryptAES(derivedSymmetricKey, JSON.parse(encSymmetricKey));
@@ -80,83 +104,16 @@ export async function login(rawEmail, rawPassphrase) {
JSON.parse(publicKey),
JSON.parse(encPrivateKey),
);
}
export function syncCreateResourceGroup(parentResourceId, name, encSymmetricKey) {
return util.post('/api/resource_groups', {
parentResourceId,
name,
encSymmetricKey,
});
}
export function syncGetResourceGroup(id) {
return util.get(`/api/resource_groups/${id}`);
}
export function syncPull(body) {
return util.post('/sync/pull', body);
}
export function syncPush(body) {
return util.post('/sync/push', body);
}
export function syncResetData() {
return util.post('/auth/reset');
}
export function syncFixDupes(resourceGroupIds) {
return util.post('/sync/fix-dupes', { ids: resourceGroupIds });
}
export function unshareWithAllTeams(resourceGroupId) {
return util.put(`/api/resource_groups/${resourceGroupId}/unshare`);
}
export async function shareWithTeam(resourceGroupId, teamId, rawPassphrase) {
// Ask the server what we need to do to invite the member
const instructions = await util.post(`/api/resource_groups/${resourceGroupId}/share-a`, {
teamId,
});
// Compute keys necessary to invite the member
const passPhrase = _sanitizePassphrase(rawPassphrase);
const { email, saltEnc, encPrivateKey, encSymmetricKey } = await whoami();
const secret = await crypt.deriveKey(passPhrase, email, saltEnc);
let symmetricKey;
try {
symmetricKey = crypt.decryptAES(secret, JSON.parse(encSymmetricKey));
} catch (err) {
throw new Error('Invalid password');
}
const privateKey = crypt.decryptAES(JSON.parse(symmetricKey), JSON.parse(encPrivateKey));
const privateKeyJWK = JSON.parse(privateKey);
const resourceGroupSymmetricKey = crypt.decryptRSAWithJWK(
privateKeyJWK,
instructions.encSymmetricKey,
);
// Build the invite data request
const newKeys = {};
for (const accountId of Object.keys(instructions.keys)) {
const accountPublicKeyJWK = JSON.parse(instructions.keys[accountId]);
newKeys[accountId] = crypt.encryptRSAWithJWK(accountPublicKeyJWK, resourceGroupSymmetricKey);
}
// Actually share it with the team
await util.post(`/api/resource_groups/${resourceGroupId}/share-b`, {
teamId,
keys: newKeys,
});
_callCallbacks();
}
export function getPublicKey() {
return getSessionData().publicKey;
return _getSessionData().publicKey;
}
export function getPrivateKey() {
const { symmetricKey, encPrivateKey } = getSessionData();
const { symmetricKey, encPrivateKey } = _getSessionData();
const privateKeyStr = crypt.decryptAES(symmetricKey, encPrivateKey);
return JSON.parse(privateKeyStr);
}
@@ -165,39 +122,39 @@ export function getCurrentSessionId() {
if (window) {
return window.localStorage.getItem('currentSessionId');
} else {
return false;
return '';
}
}
export function getAccountId() {
return getSessionData().accountId;
return _getSessionData().accountId;
}
export function getEmail() {
return getSessionData().email;
return _getSessionData().email;
}
export function getFirstName() {
return getSessionData().firstName;
return _getSessionData().firstName;
}
export function getFullName() {
const { firstName, lastName } = getSessionData();
return `${firstName || ''} ${lastName || ''}`.trim();
/** Check if we (think) we have a session */
export function isLoggedIn() {
return !!getCurrentSessionId();
}
/**
* get Data about the current session
* @returns Object
*/
export function getSessionData() {
const sessionId = getCurrentSessionId();
if (!sessionId || !window) {
return {};
/** Log out and delete session data */
export async function logout() {
try {
await fetch.post('/auth/logout', null, getCurrentSessionId());
} catch (e) {
// Not a huge deal if this fails, but we don't want it to prevent the
// user from signing out.
console.warn('Failed to logout', e);
}
const dataStr = window.localStorage.getItem(getSessionKey(sessionId));
return JSON.parse(dataStr);
_unsetSessionData();
_callCallbacks();
}
/** Set data for the new session and store it encrypted with the sessionId */
@@ -222,57 +179,48 @@ export function setSessionData(
lastName: lastName,
});
window.localStorage.setItem(getSessionKey(sessionId), dataStr);
window.localStorage.setItem(_getSessionKey(sessionId), dataStr);
// NOTE: We're setting this last because the stuff above might fail
window.localStorage.setItem('currentSessionId', sessionId);
}
/** Unset the session data (log out) */
export function unsetSessionData() {
const sessionId = getCurrentSessionId();
window.localStorage.removeItem(getSessionKey(sessionId));
window.localStorage.removeItem(`currentSessionId`);
}
/** Check if we (think) we have a session */
export function isLoggedIn() {
return getCurrentSessionId();
}
/** Log out and delete session data */
export async function logout() {
try {
await util.post('/auth/logout');
} catch (e) {
// Not a huge deal if this fails, but we don't want it to prevent the
// user from signing out.
console.warn('Failed to logout', e);
}
unsetSessionData();
}
export async function listTeams() {
return util.get('/api/teams');
return fetch.get('/api/teams', getCurrentSessionId());
}
export async function endTrial() {
await util.put('/api/billing/end-trial');
}
export function whoami(sessionId = null) {
return util.get('/auth/whoami', sessionId);
}
export function getSessionKey(sessionId) {
return `session__${(sessionId || '').slice(0, 10)}`;
await fetch.put('/api/billing/end-trial', getCurrentSessionId());
}
// ~~~~~~~~~~~~~~~~ //
// Helper Functions //
// ~~~~~~~~~~~~~~~~ //
function _whoami(sessionId = null) {
return fetch.get('/auth/whoami', sessionId || getCurrentSessionId());
}
function _getSessionData() {
const sessionId = getCurrentSessionId();
if (!sessionId || !window) {
return {};
}
const dataStr = window.localStorage.getItem(_getSessionKey(sessionId));
return JSON.parse(dataStr);
}
function _unsetSessionData() {
const sessionId = getCurrentSessionId();
window.localStorage.removeItem(_getSessionKey(sessionId));
window.localStorage.removeItem(`currentSessionId`);
}
function _getSessionKey(sessionId) {
return `session__${(sessionId || '').slice(0, 10)}`;
}
function _getSrpParams() {
return srp.params[2048];
}
@@ -2,10 +2,11 @@ import * as models from '../../models';
import * as importUtil from '../import';
import { getAppVersion } from '../constants';
import { globalBeforeEach } from '../../__jest__/before-each';
import YAML from 'yaml';
describe('exportHAR()', () => {
describe('exportWorkspacesHAR() and exportRequestsHAR()', () => {
beforeEach(globalBeforeEach);
it('exports a single workspace as an HTTP Archive', async () => {
it('exports a single workspace and some requests only as an HTTP Archive', async () => {
const wrk1 = await models.workspace.create({
_id: 'wrk_1',
name: 'Workspace 1',
@@ -52,10 +53,11 @@ describe('exportHAR()', () => {
});
const includePrivateDocs = true;
const json = await importUtil.exportHAR(wrk1, includePrivateDocs);
const data = JSON.parse(json);
expect(data).toMatchObject({
// Test export whole workspace.
const exportWorkspacesJson = await importUtil.exportWorkspacesHAR(wrk1, includePrivateDocs);
const exportWorkspacesData = JSON.parse(exportWorkspacesJson);
expect(exportWorkspacesData).toMatchObject({
log: {
entries: [
{
@@ -70,7 +72,24 @@ describe('exportHAR()', () => {
],
},
});
expect(data.log.entries.length).toBe(2);
expect(exportWorkspacesData.log.entries.length).toBe(2);
// Test export some requests only.
const exportRequestsJson = await importUtil.exportRequestsHAR([req1], includePrivateDocs);
const exportRequestsData = JSON.parse(exportRequestsJson);
expect(exportRequestsData).toMatchObject({
log: {
entries: [
{
request: {
headers: [{ name: 'X-Environment', value: 'private1' }],
},
comment: req1.name,
},
],
},
});
expect(exportRequestsData.log.entries.length).toBe(1);
});
it('exports all workspaces as an HTTP Archive', async () => {
const wrk1 = await models.workspace.create({
@@ -132,39 +151,47 @@ describe('exportHAR()', () => {
});
const includePrivateDocs = false;
const json = await importUtil.exportHAR(null, includePrivateDocs);
const json = await importUtil.exportWorkspacesHAR(null, includePrivateDocs);
const data = JSON.parse(json);
expect(data).toMatchObject({
log: {
entries: [
{
request: {
entries: expect.arrayContaining([
expect.objectContaining({
request: expect.objectContaining({
headers: [{ name: 'X-Environment', value: 'public1' }],
},
}),
comment: 'Request 1',
},
{
request: {
}),
expect.objectContaining({
request: expect.objectContaining({
headers: [{ name: 'X-Environment', value: 'base2' }],
},
}),
comment: 'Request 2',
},
],
}),
]),
},
});
});
});
describe('exportJSON()', () => {
describe('export', () => {
beforeEach(globalBeforeEach);
it('exports all workspaces', async () => {
it('exports all workspaces and some requests only', async () => {
const w = await models.workspace.create({ name: 'Workspace' });
const jar = await models.cookieJar.getOrCreateForParentId(w._id);
const r1 = await models.request.create({
name: 'Request',
name: 'Request 1',
parentId: w._id,
});
const f2 = await models.requestGroup.create({
name: 'Folder 2',
parentId: w._id,
});
const r2 = await models.request.create({
name: 'Request 2',
parentId: f2._id,
});
const eBase = await models.environment.getOrCreateForWorkspace(w);
const ePub = await models.environment.create({
name: 'Public',
@@ -176,18 +203,56 @@ describe('exportJSON()', () => {
parentId: eBase._id,
});
const json = await importUtil.exportJSON();
const data = JSON.parse(json);
// Test export whole workspace.
const exportedWorkspacesJson = await importUtil.exportWorkspacesData(null, false, 'json');
const exportedWorkspacesYaml = await importUtil.exportWorkspacesData(null, false, 'yaml');
const exportWorkspacesDataJson = JSON.parse(exportedWorkspacesJson);
const exportWorkspacesDataYaml = YAML.parse(exportedWorkspacesYaml);
expect(data._type).toBe('export');
expect(data.__export_format).toBe(3);
expect(data.__export_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/);
expect(data.__export_source).toBe(`insomnia.desktop.app:v${getAppVersion()}`);
expect(data.resources[0]._id).toBe(w._id);
expect(data.resources[1]._id).toBe(eBase._id);
expect(data.resources[2]._id).toBe(jar._id);
expect(data.resources[3]._id).toBe(r1._id);
expect(data.resources[4]._id).toBe(ePub._id);
expect(data.resources.length).toBe(5);
// Ensure JSON is the same as YAML
expect(exportWorkspacesDataJson.resources).toEqual(exportWorkspacesDataYaml.resources);
expect(exportWorkspacesDataJson).toMatchObject({
_type: 'export',
__export_format: 4,
__export_date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/),
__export_source: `insomnia.desktop.app:v${getAppVersion()}`,
resources: expect.arrayContaining([
expect.objectContaining({ _id: w._id }),
expect.objectContaining({ _id: eBase._id }),
expect.objectContaining({ _id: jar._id }),
expect.objectContaining({ _id: r1._id }),
expect.objectContaining({ _id: f2._id }),
expect.objectContaining({ _id: r2._id }),
expect.objectContaining({ _id: ePub._id }),
]),
});
expect(exportWorkspacesDataJson.resources.length).toBe(7);
// Test export some requests only.
const exportRequestsJson = await importUtil.exportRequestsData([r1], false, 'json');
const exportRequestsYaml = await importUtil.exportRequestsData([r1], false, 'yaml');
const exportRequestsDataJSON = JSON.parse(exportRequestsJson);
const exportRequestsDataYAML = YAML.parse(exportRequestsYaml);
expect(exportRequestsDataJSON).toMatchObject({
_type: 'export',
__export_format: 4,
__export_date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/),
__export_source: `insomnia.desktop.app:v${getAppVersion()}`,
resources: expect.arrayContaining([
expect.objectContaining({ _id: w._id }),
expect.objectContaining({ _id: eBase._id }),
expect.objectContaining({ _id: jar._id }),
expect.objectContaining({ _id: r1._id }),
expect.objectContaining({ _id: ePub._id }),
]),
});
expect(exportRequestsDataJSON.resources.length).toBe(5);
expect(exportRequestsDataYAML.resources.length).toBe(5);
// Ensure JSON and YAML are the same
expect(exportRequestsDataJSON.resources).toEqual(exportRequestsDataYAML.resources);
});
});
@@ -171,3 +171,25 @@ describe('fuzzyMatchAll()', () => {
expect(misc.fuzzyMatchAll('wrong this ou', ['testing', 'this', 'out'])).toEqual(null);
});
});
describe('chunkArray()', () => {
it('works with exact divisor', () => {
const chunks = misc.chunkArray([1, 2, 3, 4, 5, 6], 3);
expect(chunks).toEqual([[1, 2, 3], [4, 5, 6]]);
});
it('works with weird divisor', () => {
const chunks = misc.chunkArray([1, 2, 3, 4, 5, 6], 4);
expect(chunks).toEqual([[1, 2, 3, 4], [5, 6]]);
});
it('works with empty', () => {
const chunks = misc.chunkArray([], 4);
expect(chunks).toEqual([]);
});
it('works with less than one chunk', () => {
const chunks = misc.chunkArray([1, 2], 4);
expect(chunks).toEqual([[1, 2]]);
});
});
@@ -57,7 +57,6 @@ export function getClientString() {
// Global Stuff
export const DB_PERSIST_INTERVAL = 1000 * 60 * 60 * 2; // Compact every couple hour
export const DEBOUNCE_MILLIS = 100;
export const MAX_RESPONSES = 20;
export const REQUEST_TIME_TO_SHOW_COUNTER = 1; // Seconds
export const GA_ID = 'UA-86416787-1';
export const GA_LOCATION = 'https://desktop.insomnia.rest/';
@@ -69,6 +68,12 @@ export const FLEXIBLE_URL_REGEX = /^(http|https):\/\/[\wàâäèéêëîïôóœ
export const CHECK_FOR_UPDATES_INTERVAL = 1000 * 60 * 60 * 3; // 3 hours
export const PLUGIN_PATH = path.join(getDataDirectory(), 'plugins');
// Available editor key maps
export const EDITOR_KEY_MAP_DEFAULT = 'default';
export const EDITOR_KEY_MAP_EMACS = 'emacs';
export const EDITOR_KEY_MAP_SUBLIME = 'sublime';
export const EDITOR_KEY_MAP_VIM = 'vim';
// Hotkeys
export const MNEMONIC_SYM = isMac() ? '' : '&';
export const CTRL_SYM = isMac() ? '⌃' : 'Ctrl';
@@ -82,6 +87,9 @@ export const UPDATE_CHANNEL_BETA = 'beta';
export const UPDATE_URL_MAC = 'https://updates.insomnia.rest/builds/check/mac';
export const UPDATE_URL_WINDOWS = 'https://updates.insomnia.rest/updates/win';
// API
export const API_BASE_URL = 'https://api.insomnia.rest';
// UI Stuff
export const MAX_SIDEBAR_REMS = 45;
export const MIN_SIDEBAR_REMS = 0.75;
+51 -6
View File
@@ -77,8 +77,12 @@ export async function init(types: Array<string>, config: Object = {}, forceReset
delete db._empty;
electron.ipcMain.on('db.fn', async (e, fnName, replyChannel, ...args) => {
const result = await database[fnName](...args);
e.sender.send(replyChannel, result);
try {
const result = await database[fnName](...args);
e.sender.send(replyChannel, null, result);
} catch (err) {
e.sender.send(replyChannel, { message: err.message, stack: err.stack });
}
});
// NOTE: Only repair the DB if we're not running in memory. Repairing here causes tests to
@@ -345,7 +349,13 @@ export const insert = (database.insert = async function<T: BaseModel>(
if (db._empty) return _send('insert', ...arguments);
return new Promise(async (resolve, reject) => {
const docWithDefaults = await models.initModel(doc.type, doc);
let docWithDefaults;
try {
docWithDefaults = await models.initModel(doc.type, doc);
} catch (err) {
return reject(err);
}
db[doc.type].insert(docWithDefaults, (err, newDoc) => {
if (err) {
return reject(err);
@@ -366,7 +376,13 @@ export const update = (database.update = async function<T: BaseModel>(
if (db._empty) return _send('update', ...arguments);
return new Promise(async (resolve, reject) => {
const docWithDefaults = await models.initModel(doc.type, doc);
let docWithDefaults;
try {
docWithDefaults = await models.initModel(doc.type, doc);
} catch (err) {
return reject(err);
}
db[doc.type].update({ _id: docWithDefaults._id }, docWithDefaults, err => {
if (err) {
return reject(err);
@@ -433,6 +449,31 @@ export const removeWhere = (database.removeWhere = async function(
await database.flushChanges(flushId);
});
export const batchModifyDocs = (database.batchModifyDocs = async function(operations: {
upsert: Array<Object>,
remove: Array<Object>,
}): Promise<void> {
if (db._empty) return _send('batchModifyDocs', ...arguments);
const flushId = await bufferChanges();
const promisesUpserted = [];
const promisesDeleted = [];
for (const doc: BaseModel of operations.upsert) {
promisesUpserted.push(upsert(doc, true));
}
for (const doc: BaseModel of operations.remove) {
promisesDeleted.push(unsafeRemove(doc, true));
}
// Perform from least to most dangerous
await Promise.all(promisesUpserted);
await Promise.all(promisesDeleted);
await flushChanges(flushId);
});
// ~~~~~~~~~~~~~~~~~~~ //
// DEFAULT MODEL STUFF //
// ~~~~~~~~~~~~~~~~~~~ //
@@ -595,8 +636,12 @@ async function _send<T>(fnName: string, ...args: Array<any>): Promise<T> {
return new Promise((resolve, reject) => {
const replyChannel = `db.fn.reply:${uuid.v4()}`;
electron.ipcRenderer.send('db.fn', fnName, replyChannel, ...args);
electron.ipcRenderer.once(replyChannel, (e, result) => {
resolve(result);
electron.ipcRenderer.once(replyChannel, (e, err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
}
-87
View File
@@ -1,87 +0,0 @@
import 'whatwg-fetch';
import { parse as urlParse } from 'url';
import { getClientString } from './constants';
import * as session from '../sync/session';
import * as zlib from 'zlib';
let commandListeners = [];
export function onCommand(callback) {
commandListeners.push(callback);
}
export function offCommand(callback) {
commandListeners = commandListeners.filter(l => l !== callback);
}
export function post(path, obj) {
return _fetch('POST', path, obj);
}
export function get(path, sessionId = null) {
return _fetch('GET', path, null, sessionId);
}
export function del(path, sessionId = null) {
return _fetch('DELETE', path, null, sessionId);
}
export function put(path, sessionId = null) {
return _fetch('PUT', path, null, sessionId);
}
export function rawFetch(...args) {
return window.fetch(...args);
}
async function _fetch(method, path, obj, sessionId = null) {
const config = {
method: method,
headers: new window.Headers(),
};
// Set some client information
config.headers.set('X-Insomnia-Client', getClientString());
if (obj) {
config.body = zlib.gzipSync(JSON.stringify(obj));
config.headers.set('Content-Type', 'application/json');
config.headers.set('Content-Encoding', 'gzip');
}
sessionId = sessionId || session.getCurrentSessionId();
if (sessionId) {
config.headers.set('X-Session-Id', sessionId);
}
const response = await window.fetch(_getUrl(path), config);
const uri = response.headers.get('x-insomnia-command');
uri && _notifyCommandListeners(uri);
if (!response.ok) {
const err = new Error(`Response ${response.status} for ${path}`);
err.message = await response.text();
err.statusCode = response.status;
throw err;
}
if (response.headers.get('content-type') === 'application/json' || path.match(/\.json$/)) {
return response.json();
} else {
return response.text();
}
}
function _getUrl(path) {
const baseUrl = process.env.INSOMNIA_SYNC_URL || 'https://api.insomnia.rest';
return `${baseUrl}${path}`;
}
function _notifyCommandListeners(uri) {
const parsed = urlParse(uri, true);
const command = `${parsed.hostname}${parsed.pathname}`;
const args = JSON.parse(JSON.stringify(parsed.query));
commandListeners.map(fn => fn(command, args));
}
+16 -12
View File
@@ -441,21 +441,25 @@ function getResponseContent(response: ResponseModel): HarContent {
}
function getResponseHeaders(response: ResponseModel): Array<HarHeader> {
return response.headers.filter(header => header.name).map(h => {
return {
name: h.name,
value: h.value,
};
});
return response.headers
.filter(header => header.name)
.map(h => {
return {
name: h.name,
value: h.value,
};
});
}
function getRequestHeaders(renderedRequest: RenderedRequest): Array<HarHeader> {
return renderedRequest.headers.filter(header => header.name).map(header => {
return {
name: header.name,
value: header.value,
};
});
return renderedRequest.headers
.filter(header => header.name)
.map(header => {
return {
name: header.name,
value: header.value,
};
});
}
function getRequestQueryString(renderedRequest: RenderedRequest): Array<HarQueryString> {
+108 -41
View File
@@ -1,5 +1,6 @@
// @flow
import { convert } from 'insomnia-importers';
import clone from 'clone';
import * as db from './database';
import * as har from './har';
import type { BaseModel } from '../models/index';
@@ -7,13 +8,13 @@ import * as models from '../models/index';
import { getAppVersion } from './constants';
import { showModal } from '../ui/components/modals/index';
import AlertModal from '../ui/components/modals/alert-modal';
import * as fetch from './fetch';
import fs from 'fs';
import type { Workspace } from '../models/workspace';
import type { Environment } from '../models/environment';
import { fnOrString, generateId } from './misc';
import YAML from 'yaml';
const EXPORT_FORMAT = 3;
const EXPORT_FORMAT = 4;
const EXPORT_TYPE_REQUEST = 'request';
const EXPORT_TYPE_REQUEST_GROUP = 'request_group';
@@ -35,7 +36,7 @@ const MODELS = {
export async function importUri(workspaceId: string | null, uri: string): Promise<void> {
let rawText;
if (uri.match(/^(http|https):\/\//)) {
const response = await fetch.rawFetch(uri);
const response = await window.fetch(uri);
rawText = await response.text();
} else if (uri.match(/^(file):\/\//)) {
const path = uri.replace(/^(file):\/\//, '');
@@ -188,52 +189,81 @@ export async function importRaw(
};
}
export async function exportHAR(
export async function exportWorkspacesHAR(
parentDoc: BaseModel | null = null,
includePrivateDocs: boolean = false,
): Promise<string> {
let workspaces;
if (parentDoc) {
workspaces = [parentDoc];
} else {
workspaces = await models.workspace.all();
const docs: Array<BaseModel> = await getDocWithDescendants(parentDoc, includePrivateDocs);
const requests: Array<BaseModel> = docs.filter(doc => doc.type === models.request.type);
return exportRequestsHAR(requests, includePrivateDocs);
}
export async function exportRequestsHAR(
requests: Array<BaseModel>,
includePrivateDocs: boolean = false,
): Promise<string> {
const workspaces: Array<BaseModel> = [];
const mapRequestIdToWorkspace: Object = {};
const workspaceLookup: Object = {};
for (const request of requests) {
const ancestors: Array<BaseModel> = await db.withAncestors(request, [
models.workspace.type,
models.requestGroup.type,
]);
const workspace = ancestors.find(ancestor => ancestor.type === models.workspace.type);
mapRequestIdToWorkspace[request._id] = workspace;
if (workspace == null || workspaceLookup.hasOwnProperty(workspace._id)) {
continue;
}
workspaceLookup[workspace._id] = true;
workspaces.push(workspace);
}
const workspaceEnvironmentLookup = {};
for (let workspace of workspaces) {
const mapWorkspaceIdToEnvironmentId: Object = {};
for (const workspace of workspaces) {
const workspaceMeta = await models.workspaceMeta.getByParentId(workspace._id);
let environmentId = workspaceMeta ? workspaceMeta.activeEnvironmentId : null;
const environment = await models.environment.getById(environmentId || 'n/a');
if (!environment || (environment.isPrivate && !includePrivateDocs)) {
environmentId = 'n/a';
}
workspaceEnvironmentLookup[workspace._id] = environmentId;
mapWorkspaceIdToEnvironmentId[workspace._id] = environmentId;
}
const requests = [];
for (let workspace of workspaces) {
const docs: Array<BaseModel> = await getDocWithDescendants(workspace, includePrivateDocs);
const workspaceRequests = docs
.filter(d => d.type === models.request.type)
.sort((a: Object, b: Object) => (a.metaSortKey < b.metaSortKey ? -1 : 1))
.map((request: BaseModel) => {
return {
requestId: request._id,
environmentId: workspaceEnvironmentLookup[workspace._id],
};
});
requests.push(...workspaceRequests);
requests = requests.sort((a: Object, b: Object) => (a.metaSortKey < b.metaSortKey ? -1 : 1));
const harRequests: Array<Object> = [];
for (const request of requests) {
const workspace = mapRequestIdToWorkspace[request._id];
if (workspace == null) {
// Workspace not found for request, so don't export it.
continue;
}
const environmentId = mapWorkspaceIdToEnvironmentId[workspace._id];
harRequests.push({
requestId: request._id,
environmentId: environmentId,
});
}
const data = await har.exportHar(requests);
const data = await har.exportHar(harRequests);
return JSON.stringify(data, null, '\t');
}
export async function exportJSON(
parentDoc: BaseModel | null = null,
includePrivateDocs: boolean = false,
export async function exportWorkspacesData(
parentDoc: BaseModel | null,
includePrivateDocs: boolean,
format: 'json' | 'yaml',
): Promise<string> {
const docs: Array<BaseModel> = await getDocWithDescendants(parentDoc, includePrivateDocs);
const requests: Array<BaseModel> = docs.filter(doc => doc.type === models.request.type);
return exportRequestsData(requests, includePrivateDocs, format);
}
export async function exportRequestsData(
requests: Array<BaseModel>,
includePrivateDocs: boolean,
format: 'json' | 'yaml',
): Promise<string> {
const data = {
_type: 'export',
@@ -243,18 +273,49 @@ export async function exportJSON(
resources: [],
};
const docs: Array<BaseModel> = await getDocWithDescendants(parentDoc, includePrivateDocs);
const docs: Array<BaseModel> = [];
const workspaces: Array<BaseModel> = [];
const mapTypeAndIdToDoc: Object = {};
for (const req of requests) {
const ancestors: Array<BaseModel> = clone(await db.withAncestors(req));
for (const ancestor of ancestors) {
const key = ancestor.type + '___' + ancestor._id;
if (mapTypeAndIdToDoc.hasOwnProperty(key)) {
continue;
}
mapTypeAndIdToDoc[key] = ancestor;
docs.push(ancestor);
if (ancestor.type === models.workspace.type) {
workspaces.push(ancestor);
}
}
}
for (const workspace of workspaces) {
const descendants: Array<BaseModel> = (await db.withDescendants(workspace)).filter(d => {
// Only interested in these additional model types.
return d.type === models.cookieJar.type || d.type === models.environment.type;
});
docs.push(...descendants);
}
data.resources = docs
.filter(
d =>
// Only export these model types
d.type === models.request.type ||
d.type === models.requestGroup.type ||
d.type === models.workspace.type ||
d.type === models.cookieJar.type ||
d.type === models.environment.type,
)
.filter(d => {
// Only export these model types.
if (
!(
d.type === models.request.type ||
d.type === models.requestGroup.type ||
d.type === models.workspace.type ||
d.type === models.cookieJar.type ||
d.type === models.environment.type
)
) {
return false;
}
// BaseModel doesn't have isPrivate, so cast it first.
return !(d: Object).isPrivate || includePrivateDocs;
})
.map((d: Object) => {
if (d.type === models.workspace.type) {
d._type = EXPORT_TYPE_WORKSPACE;
@@ -273,7 +334,13 @@ export async function exportJSON(
return d;
});
return JSON.stringify(data, null, '\t');
if (format.toLowerCase() === 'yaml') {
return YAML.stringify(data);
} else if (format.toLowerCase() === 'json') {
return JSON.stringify(data);
} else {
throw new Error(`Invalid export format ${format}. Must be "json" or "yaml"`);
}
}
async function getDocWithDescendants(
+9
View File
@@ -339,3 +339,12 @@ export function getDataDirectory(): string {
const { app } = electron.remote || electron;
return process.env.INSOMNIA_DATA_PATH || app.getPath('userData');
}
export function chunkArray<T>(arr: Array<T>, chunkSize: number): Array<Array<T>> {
const chunks = [];
for (let i = 0, j = arr.length; i < j; i += chunkSize) {
chunks.push(arr.slice(i, i + chunkSize));
}
return chunks;
}
+13 -2
View File
@@ -80,8 +80,19 @@ function _launchApp() {
});
// Called when second instance launched with args (Windows)
app.makeSingleInstance(args => {
args.length && window.send('run-command', args[0]);
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
console.error('[app] Failed to get instance lock');
return;
}
app.on('second-instance', (event, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (window) {
if (window.isMinimized()) window.restore();
window.focus();
}
});
// Handle URLs when app already open
+18 -28
View File
@@ -149,27 +149,27 @@ export function createWindow() {
label: `${MNEMONIC_SYM}Actual Size`,
accelerator: 'CmdOrCtrl+0',
click: () => {
const window = BrowserWindow.getFocusedWindow();
if (!window || !window.webContents) {
const w = BrowserWindow.getFocusedWindow();
if (!w || !w.webContents) {
return;
}
const zoomFactor = 1;
window.webContents.setZoomFactor(zoomFactor);
w.webContents.setZoomFactor(zoomFactor);
saveZoomFactor(zoomFactor);
},
},
{
label: `Zoom ${MNEMONIC_SYM}In`,
accelerator: isMac() ? 'CmdOrCtrl+Plus' : 'CmdOrCtrl+=',
accelerator: 'CmdOrCtrl+=',
click: () => {
const window = BrowserWindow.getFocusedWindow();
if (!window || !window.webContents) {
const w = BrowserWindow.getFocusedWindow();
if (!w || !w.webContents) {
return;
}
const zoomFactor = Math.min(1.8, getZoomFactor() + 0.05);
window.webContents.setZoomFactor(zoomFactor);
w.webContents.setZoomFactor(zoomFactor);
saveZoomFactor(zoomFactor);
},
@@ -178,27 +178,16 @@ export function createWindow() {
label: `Zoom ${MNEMONIC_SYM}Out`,
accelerator: 'CmdOrCtrl+-',
click: () => {
const window = BrowserWindow.getFocusedWindow();
if (!window || !window.webContents) {
const w = BrowserWindow.getFocusedWindow();
if (!w || !w.webContents) {
return;
}
const zoomFactor = Math.max(0.5, getZoomFactor() - 0.05);
window.webContents.setZoomFactor(zoomFactor);
w.webContents.setZoomFactor(zoomFactor);
saveZoomFactor(zoomFactor);
},
},
{
label: `Toggle ${MNEMONIC_SYM}Sidebar`,
click: () => {
const window = BrowserWindow.getFocusedWindow();
if (!window || !window.webContents) {
return;
}
window.webContents.send('toggle-sidebar');
},
},
{
label: `Toggle ${MNEMONIC_SYM}DevTools`,
click: () => mainWindow.toggleDevTools(),
@@ -228,16 +217,17 @@ export function createWindow() {
},
{
label: `${MNEMONIC_SYM}Keyboard Shortcuts`,
click: (menuItem, window, e) => {
if (!window || !window.webContents) {
accelerator: 'CmdOrCtrl+Shift+?',
click: (menuItem, w, e) => {
if (!w || !w.webContents) {
return;
}
window.webContents.send('toggle-preferences-shortcuts');
w.webContents.send('toggle-preferences-shortcuts');
},
},
{
label: `Show App ${MNEMONIC_SYM}Data Folder`,
click: (menuItem, window, e) => {
click: (menuItem, w, e) => {
const directory = misc.getDataDirectory();
shell.showItemInFolder(directory);
},
@@ -317,12 +307,12 @@ export function createWindow() {
{
label: `${MNEMONIC_SYM}Reload Plugins`,
click: () => {
const window = BrowserWindow.getFocusedWindow();
if (!window || !window.webContents) {
const w = BrowserWindow.getFocusedWindow();
if (!w || !w.webContents) {
return;
}
window.webContents.send('reload-plugins');
w.webContents.send('reload-plugins');
},
},
],
@@ -6,6 +6,7 @@ export const name = 'Client Certificate';
export const type = 'ClientCertificate';
export const prefix = 'crt';
export const canDuplicate = true;
export const canSync = false;
type BaseClientCertificate = {
parentId: string,
+16 -5
View File
@@ -1,4 +1,5 @@
// @flow
import crypto from 'crypto';
import * as db from '../common/database';
import type { BaseModel } from './index';
@@ -6,6 +7,7 @@ export const name = 'Cookie Jar';
export const type = 'CookieJar';
export const prefix = 'jar';
export const canDuplicate = true;
export const canSync = false;
export type Cookie = {
id: string,
@@ -44,7 +46,7 @@ export function migrate(doc: CookieJar): CookieJar {
return doc;
}
export function create(patch: Object = {}) {
export async function create(patch: Object = {}) {
if (!patch.parentId) {
throw new Error(`New CookieJar missing \`parentId\`: ${JSON.stringify(patch)}`);
}
@@ -55,21 +57,30 @@ export function create(patch: Object = {}) {
export async function getOrCreateForParentId(parentId: string) {
const cookieJars = await db.find(type, { parentId });
if (cookieJars.length === 0) {
return create({ parentId });
return create({
parentId,
// Deterministic ID. It helps reduce sync complexity since we won't have to
// de-duplicate environments.
_id: `${prefix}_${crypto
.createHash('sha1')
.update(parentId)
.digest('hex')}`,
});
} else {
return cookieJars[0];
}
}
export function all() {
export async function all(): Promise<Array<BaseModel>> {
return db.all(type);
}
export function getById(id: string) {
export async function getById(id: string) {
return db.get(type, id);
}
export function update(cookieJar: CookieJar, patch: Object = {}) {
export async function update(cookieJar: CookieJar, patch: Object = {}) {
return db.docUpdate(cookieJar, patch);
}
@@ -1,4 +1,5 @@
// @flow
import * as crypto from 'crypto';
import * as db from '../common/database';
import type { BaseModel } from './index';
import type { Workspace } from './workspace';
@@ -7,6 +8,7 @@ export const name = 'Environment';
export const type = 'Environment';
export const prefix = 'env';
export const canDuplicate = true;
export const canSync = true;
type BaseEnvironment = {
name: string,
@@ -57,6 +59,13 @@ export async function getOrCreateForWorkspaceId(workspaceId: string): Promise<En
return create({
parentId: workspaceId,
name: 'Base Environment',
// Deterministic base env ID. It helps reduce sync complexity since we won't have to
// de-duplicate environments.
_id: `${prefix}_${crypto
.createHash('sha1')
.update(workspaceId)
.digest('hex')}`,
});
}
+14 -1
View File
@@ -61,10 +61,23 @@ export function all() {
];
}
export function types() {
export function types(): Array<any> {
return all().map(model => model.type);
}
export function canSync(d: BaseModel): boolean {
if ((d: any).isPrivate) {
return false;
}
const m = getModel(d.type);
if (!m) {
return false;
}
return m.canSync || false;
}
export function getModel(type: string): Object | null {
return all().find(m => m.type === type) || null;
}
@@ -24,6 +24,7 @@ export const name = 'OAuth 2.0 Token';
export const type = 'OAuth2Token';
export const prefix = 'oa2';
export const canDuplicate = false;
export const canSync = false;
export function init(): BaseOAuth2Token {
return {
@@ -6,6 +6,7 @@ export const name = 'PluginData';
export const type = 'PluginData';
export const prefix = 'plg';
export const canDuplicate = false;
export const canSync = false;
type BasePluginData = {
plugin: string,
@@ -4,6 +4,7 @@ export const name = 'Folder Meta';
export const type = 'RequestGroupMeta';
export const prefix = 'fldm';
export const canDuplicate = false;
export const canSync = false;
export function init() {
return {
@@ -6,6 +6,7 @@ export const name = 'Folder';
export const type = 'RequestGroup';
export const prefix = 'fld';
export const canDuplicate = true;
export const canSync = true;
type BaseRequestGroup = {
name: string,
@@ -5,6 +5,7 @@ export const name = 'Request Meta';
export const type = 'RequestMeta';
export const prefix = 'reqm';
export const canDuplicate = false;
export const canSync = false;
export function init() {
return {
@@ -6,6 +6,7 @@ export const name = 'Request Version';
export const type = 'RequestVersion';
export const prefix = 'rvr';
export const canDuplicate = false;
export const canSync = false;
const FIELDS_TO_IGNORE = [
'_id',
+2 -1
View File
@@ -32,6 +32,7 @@ export const name = 'Request';
export const type = 'Request';
export const prefix = 'req';
export const canDuplicate = true;
export const canSync = true;
export type RequestAuthentication = Object;
@@ -357,7 +358,7 @@ export function remove(request: Request): Promise<void> {
return db.remove(request);
}
export function all() {
export async function all(): Promise<Array<Request>> {
return db.all(type);
}
+8 -4
View File
@@ -8,7 +8,6 @@ import crypto from 'crypto';
import path from 'path';
import zlib from 'zlib';
import mkdirp from 'mkdirp';
import { MAX_RESPONSES } from '../common/constants';
import * as db from '../common/database';
import { getDataDirectory } from '../common/misc';
@@ -16,6 +15,7 @@ export const name = 'Response';
export const type = 'Response';
export const prefix = 'res';
export const canDuplicate = false;
export const canSync = false;
export type ResponseHeader = {
name: string,
@@ -97,7 +97,7 @@ export function getById(id: string) {
return db.get(type, id);
}
export function all() {
export async function all(): Promise<Array<Response>> {
return db.all(type);
}
@@ -123,7 +123,7 @@ export async function getLatestForRequest(requestId: string): Promise<Response |
return response || null;
}
export async function create(patch: Object = {}) {
export async function create(patch: Object = {}, maxResponses: number = 20) {
if (!patch.parentId) {
throw new Error('New Response missing `parentId`');
}
@@ -136,7 +136,11 @@ export async function create(patch: Object = {}) {
patch.requestVersionId = requestVersion ? requestVersion._id : null;
// Delete all other responses before creating the new one
const allResponses = await db.findMostRecentlyModified(type, { parentId }, MAX_RESPONSES);
const allResponses = await db.findMostRecentlyModified(
type,
{ parentId },
Math.max(1, maxResponses),
);
const recentIds = allResponses.map(r => r._id);
await db.removeWhere(type, { parentId, _id: { $nin: recentIds } });
@@ -23,6 +23,7 @@ type BaseSettings = {
autoHideMenuBar: boolean,
theme: string,
maxRedirects: number,
maxHistoryResponses: number,
pluginPath: string,
nunjucksPowerUserMode: boolean,
deviceId: string | null,
@@ -35,6 +36,9 @@ type BaseSettings = {
fontInterface: string | null,
fontSize: number,
fontVariantLigatures: boolean,
// Feature flags
enableSyncBeta: boolean,
hotKeyRegistry: hotkeys.HotKeyRegistry,
};
@@ -44,6 +48,7 @@ export const name = 'Settings';
export const type = 'Settings';
export const prefix = 'set';
export const canDuplicate = false;
export const canSync = false;
export function init(): BaseSettings {
return {
@@ -59,6 +64,7 @@ export function init(): BaseSettings {
httpsProxy: '',
noProxy: '',
maxRedirects: -1,
maxHistoryResponses: 20,
proxyEnabled: false,
timeout: 0,
validateSSL: true,
@@ -77,6 +83,7 @@ export function init(): BaseSettings {
fontInterface: null,
fontSize: 13,
fontVariantLigatures: false,
enableSyncBeta: false,
hotKeyRegistry: hotkeys.newDefaultRegistry(),
};
}
@@ -6,6 +6,7 @@ export const name = 'Stats';
export const type = 'Stats';
export const prefix = 'sta';
export const canDuplicate = false;
export const canSync = false;
type BaseStats = {
currentLaunch: number | null,
@@ -11,6 +11,7 @@ export const name = 'Workspace Meta';
export const type = 'WorkspaceMeta';
export const prefix = 'wrkm';
export const canDuplicate = false;
export const canSync = false;
type BaseWorkspaceMeta = {
activeRequestId: string | null,
@@ -7,6 +7,7 @@ export const name = 'Workspace';
export const type = 'Workspace';
export const prefix = 'wrk';
export const canDuplicate = true;
export const canSync = true;
type BaseWorkspace = {
name: string,
+15 -13
View File
@@ -626,19 +626,21 @@ export async function _actuallySend(
}
// NOTE: This is last because headers might be modified multiple times
const headerStrings = headers.filter(h => h.name).map(h => {
const value = h.value || '';
if (value === '') {
// Curl needs a semicolon suffix to send empty header values
return `${h.name};`;
} else if (value === DISABLE_HEADER_VALUE) {
// Tell Curl NOT to send the header if value is null
return `${h.name}:`;
} else {
// Send normal header value
return `${h.name}: ${value}`;
}
});
const headerStrings = headers
.filter(h => h.name)
.map(h => {
const value = h.value || '';
if (value === '') {
// Curl needs a semicolon suffix to send empty header values
return `${h.name};`;
} else if (value === DISABLE_HEADER_VALUE) {
// Tell Curl NOT to send the header if value is null
return `${h.name}:`;
} else {
// Send normal header value
return `${h.name}: ${value}`;
}
});
setOpt(Curl.option.HTTPHEADER, headerStrings);
let responseBodyBytes = 0;
@@ -10,6 +10,7 @@ export const RESPONSE_TYPE_TOKEN = 'token';
export const RESPONSE_TYPE_ID_TOKEN_TOKEN = 'id_token token';
export const P_ACCESS_TOKEN = 'access_token';
export const P_ID_TOKEN = 'id_token';
export const P_CLIENT_ID = 'client_id';
export const P_CLIENT_SECRET = 'client_secret';
export const P_AUDIENCE = 'audience';
@@ -36,12 +36,17 @@ export default async function(
const qs = buildQueryStringFromParams(params);
const finalUrl = joinUrlAndQueryString(authorizationUrl, qs);
const redirectedTo = await authorizeUserInWindow(finalUrl, /(access_token=)/, /(error=)/);
const redirectedTo = await authorizeUserInWindow(
finalUrl,
/(access_token=|id_token=)/,
/(error=)/,
);
const fragment = redirectedTo.split('#')[1];
if (fragment) {
return responseToObject(fragment, [
const results = responseToObject(fragment, [
c.P_ACCESS_TOKEN,
c.P_ID_TOKEN,
c.P_TOKEN_TYPE,
c.P_EXPIRES_IN,
c.P_SCOPE,
@@ -50,6 +55,9 @@ export default async function(
c.P_ERROR_DESCRIPTION,
c.P_ERROR_URI,
]);
results[c.P_ACCESS_TOKEN] = results[c.P_ACCESS_TOKEN] || results[c.P_ID_TOKEN];
delete results[c.P_ID_TOKEN];
return results;
} else {
// Bad redirect
return {};
@@ -1,37 +1,37 @@
{
"_type": "export",
"__export_format": 3,
"__export_date": "2017-11-24T16:14:14.805Z",
"__export_source": "insomnia.desktop.app:v5.12.0-beta.3",
"resources": [
{
"_id": "wrk_imported_1",
"created": 888,
"_type": "export",
"__export_format": 4,
"__export_date": "2017-11-24T16:14:14.805Z",
"__export_source": "insomnia.desktop.app:v5.12.0-beta.3",
"resources": [
{
"_id": "wrk_imported_1",
"created": 888,
"modified": 999,
"description": "",
"name": "New",
"parentId": null,
"_type": "workspace"
},
{
"_id": "req_imported_1",
"authentication": {},
"body": {},
"description": "",
"name": "New",
"parentId": null,
"_type": "workspace"
},
{
"_id": "req_imported_1",
"authentication": {},
"body": {},
"created": 111,
"modified": 222,
"metaSortKey": 0,
"description": "",
"headers": [],
"method": "GET",
"name": "Test",
"parameters": [],
"parentId": "wrk_imported_1",
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingSendCookies": true,
"settingStoreCookies": true,
"url": "https://insomnia.rest",
"_type": "request"
}
]
"description": "",
"headers": [],
"method": "GET",
"name": "Test",
"parameters": [],
"parentId": "wrk_imported_1",
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingSendCookies": true,
"settingStoreCookies": true,
"url": "https://insomnia.rest",
"_type": "request"
}
]
}
@@ -168,10 +168,10 @@ describe('app.export.*', () => {
exportedData.__export_date = '2017-11-24T18:09:18.480Z';
expect(exportedData).toEqual({
__export_date: '2017-11-24T18:09:18.480Z',
__export_format: 3,
__export_format: 4,
__export_source: `insomnia.desktop.app:v${getAppVersion()}`,
_type: 'export',
resources: [
resources: expect.arrayContaining([
{
_id: 'wrk_1',
_type: 'workspace',
@@ -204,7 +204,7 @@ describe('app.export.*', () => {
settingMaxTimelineDataSize: 1000,
url: 'https://insomnia.rest',
},
],
]),
});
});
@@ -1,5 +1,10 @@
// @flow
import { exportHAR, exportJSON, importRaw, importUri } from '../../common/import';
import {
exportWorkspacesHAR,
exportWorkspacesData,
importRaw,
importUri,
} from '../../common/import';
export function init(): { import: Object, export: Object } {
return {
@@ -12,12 +17,14 @@ export function init(): { import: Object, export: Object } {
},
},
export: {
async insomnia(options: { includePrivate?: boolean } = {}): Promise<string> {
async insomnia(
options: { includePrivate?: boolean, format?: 'json' | 'yaml' } = {},
): Promise<string> {
options = options || {};
return exportJSON(null, options.includePrivate);
return exportWorkspacesData(null, !!options.includePrivate, options.format || 'json');
},
async har(options: { includePrivate?: boolean } = {}): Promise<string> {
return exportHAR(null, options.includePrivate);
return exportWorkspacesHAR(null, !!options.includePrivate);
},
},
};
+16 -11
View File
@@ -78,21 +78,23 @@ async function _isInsomniaPlugin(lookupName: string): Promise<Object> {
},
},
(err, stdout, stderr) => {
if (err) {
reject(new Error(`${lookupName} npm error: ${err.message}`));
return;
}
if (stderr) {
reject(new Error(`Yarn error ${stderr.toString('utf8')}`));
reject(new Error(`Yarn error ${stderr.toString()}`));
return;
}
let yarnOutput;
try {
yarnOutput = JSON.parse(stdout.toString('utf8'));
} catch (err) {
reject(new Error(`Yarn response not JSON: ${err.message}`));
yarnOutput = JSON.parse(stdout.toString());
} catch (ex) {
// Output is not JSON. Check if yarn/electron terminated with non-zero exit code.
// In certain environments electron can exit with error even if output is OK.
// Parsing is attemted before checking exit code as workaround for false errors.
if (err) {
reject(new Error(`${lookupName} npm error: ${err.message}`));
} else {
reject(new Error(`Yarn response not JSON: ${ex.message}`));
}
return;
}
@@ -149,13 +151,16 @@ async function _installPluginToTmpDir(lookupName: string): Promise<{ tmpDir: str
},
},
(err, stdout, stderr) => {
if (err) {
// Check yarn/electron process exit code.
// In certain environments electron can exit with error even if the command was perfomed sucesfully.
// Checking for sucess message in output is a workaround for false errors.
if (err && !stdout.toString().includes('success')) {
reject(new Error(`${lookupName} install error: ${err.message}`));
return;
}
if (stderr) {
reject(new Error(`Yarn error ${stderr.toString('utf8')}`));
reject(new Error(`Yarn error ${stderr.toString()}`));
return;
}
@@ -1,10 +1,11 @@
import * as sync from '../index';
import * as session from '../session';
import * as models from '../../models';
import * as db from '../../common/database';
import * as syncStorage from '../storage';
import * as crypt from '../crypt';
import * as network from '../network';
import { globalBeforeEach } from '../../__jest__/before-each';
import * as session from '../../account/session';
import * as crypt from '../../account/crypt';
describe('Test push/pull behaviour', () => {
beforeEach(async () => {
@@ -59,16 +60,16 @@ describe('Test push/pull behaviour', () => {
await sync.push(resourceRequest.resourceGroupId); // Force push rg_1
await sync.push(resourceRequest2.resourceGroupId); // Force push rg_2
expect(session.syncPush.mock.calls.length).toBe(3);
expect(session.syncPush.mock.calls[0][0].length).toBe(2);
expect(session.syncPush.mock.calls[0][0][0].id).toBe('wrk_1');
expect(session.syncPush.mock.calls[0][0][1].id).toBe('req_1');
expect(session.syncPush.mock.calls[1][0].length).toBe(2);
expect(session.syncPush.mock.calls[1][0][0].id).toBe('wrk_1');
expect(session.syncPush.mock.calls[1][0][1].id).toBe('req_1');
expect(session.syncPush.mock.calls[2][0].length).toBe(2);
expect(session.syncPush.mock.calls[2][0][0].id).toBe('wrk_2');
expect(session.syncPush.mock.calls[2][0][1].id).toBe('req_2');
expect(network.syncPush.mock.calls.length).toBe(3);
expect(network.syncPush.mock.calls[0][0].length).toBe(2);
expect(network.syncPush.mock.calls[0][0][0].id).toBe('wrk_1');
expect(network.syncPush.mock.calls[0][0][1].id).toBe('req_1');
expect(network.syncPush.mock.calls[1][0].length).toBe(2);
expect(network.syncPush.mock.calls[1][0][0].id).toBe('wrk_1');
expect(network.syncPush.mock.calls[1][0][1].id).toBe('req_1');
expect(network.syncPush.mock.calls[2][0].length).toBe(2);
expect(network.syncPush.mock.calls[2][0][0].id).toBe('wrk_2');
expect(network.syncPush.mock.calls[2][0][1].id).toBe('req_2');
});
it('Updates dirty flag for push response', async () => {
@@ -78,7 +79,7 @@ describe('Test push/pull behaviour', () => {
syncMode: syncStorage.SYNC_MODE_ON,
});
session.syncPush.mockReturnValueOnce({
network.syncPush.mockReturnValueOnce({
updated: [],
created: [{ id: request._id, version: 'new-version' }],
removed: [],
@@ -89,10 +90,10 @@ describe('Test push/pull behaviour', () => {
await sync.push(resourceRequest.resourceGroupId);
const resourceAfter = await syncStorage.getResourceByDocId(request._id);
expect(session.syncPush.mock.calls.length).toBe(1);
expect(session.syncPush.mock.calls[0][0].length).toBe(2);
expect(session.syncPush.mock.calls[0][0][0].id).toBe('wrk_1');
expect(session.syncPush.mock.calls[0][0][1].id).toBe('req_1');
expect(network.syncPush.mock.calls.length).toBe(1);
expect(network.syncPush.mock.calls[0][0].length).toBe(2);
expect(network.syncPush.mock.calls[0][0][0].id).toBe('wrk_1');
expect(network.syncPush.mock.calls[0][0][1].id).toBe('req_1');
expect(resourceBefore.dirty).toBe(true);
expect(resourceAfter.dirty).toBe(false);
});
@@ -122,7 +123,7 @@ describe('Test push/pull behaviour', () => {
encContent: await sync.encryptDoc(resourceBefore.resourceGroupId, requestNew),
});
session.syncPull.mockReturnValueOnce({
network.syncPull.mockReturnValueOnce({
updatedResources: [updatedResource],
createdResources: [createdResourceNew],
idsToPush: [],
@@ -165,7 +166,7 @@ describe('Test push/pull behaviour', () => {
lastEdited: resourceRequest.lastEdited - 1000, // Same edited time
});
session.syncPush.mockReturnValueOnce({
network.syncPush.mockReturnValueOnce({
updated: [],
created: [],
removed: [],
@@ -180,8 +181,8 @@ describe('Test push/pull behaviour', () => {
const requestAfter = await models.request.getById(requestClient._id);
// Assert
expect(session.syncPush.mock.calls.length).toBe(1);
expect(session.syncPush.mock.calls[0][0].length).toBe(2);
expect(network.syncPush.mock.calls.length).toBe(1);
expect(network.syncPush.mock.calls[0][0].length).toBe(2);
// Even when local wins, local resource gets the remove resource version
expect(resourceAfter.version).toBe(resourceConflict.version);
// Local resource gets marked as dirty so it's pushed right away
@@ -202,7 +203,7 @@ describe('Test push/pull behaviour', () => {
lastEdited: resourceRequest.lastEdited, // Same edited time
});
session.syncPush.mockReturnValueOnce({
network.syncPush.mockReturnValueOnce({
updated: [],
created: [],
removed: [],
@@ -217,8 +218,8 @@ describe('Test push/pull behaviour', () => {
const requestAfter = await models.request.getById(requestClient._id);
// Assert
expect(session.syncPush.mock.calls.length).toBe(1);
expect(session.syncPush.mock.calls[0][0].length).toBe(2);
expect(network.syncPush.mock.calls.length).toBe(1);
expect(network.syncPush.mock.calls[0][0].length).toBe(2);
// Even when local wins, local resource gets the remove resource version
expect(resourceAfter.version).toBe(resourceConflict.version);
// Local resource gets marked as dirty so it's pushed right away
@@ -239,7 +240,7 @@ describe('Test push/pull behaviour', () => {
lastEdited: resourceRequest.lastEdited + 1000,
});
session.syncPush.mockReturnValueOnce({
network.syncPush.mockReturnValueOnce({
updated: [],
created: [],
removed: [],
@@ -254,8 +255,8 @@ describe('Test push/pull behaviour', () => {
const requestAfter = await models.request.getById(requestClient._id);
// Assert
expect(session.syncPush.mock.calls.length).toBe(1);
expect(session.syncPush.mock.calls[0][0].length).toBe(2);
expect(network.syncPush.mock.calls.length).toBe(1);
expect(network.syncPush.mock.calls[0][0].length).toBe(2);
expect(resourceAfter.lastEdited).toBeGreaterThan(resourceRequest.lastEdited);
expect(resourceAfter.version).toBe(resourceConflict.version);
// Local resource gets marked as dirty so it's pushed right away
@@ -365,10 +366,10 @@ describe('Integration tests for creating Resources and pushing', () => {
expect(_decryptResource(resource).url).toBe('https://google.com');
expect(resource.removed).toBe(false);
expect(session.syncPush.mock.calls.length).toBe(1);
expect(session.syncPush.mock.calls[0][0].length).toBe(6);
expect(network.syncPush.mock.calls.length).toBe(1);
expect(network.syncPush.mock.calls[0][0].length).toBe(6);
expect(session.syncPull.mock.calls).toEqual([]);
expect(network.syncPull.mock.calls).toEqual([]);
});
it('Resources revived on DB change', async () => {
@@ -425,10 +426,10 @@ describe('Integration tests for creating Resources and pushing', () => {
expect(_decryptResource(updatedResource).name).toBe('New Name');
expect(resource.removed).toBe(false);
expect(session.syncPush.mock.calls.length).toBe(1);
expect(session.syncPush.mock.calls[0][0].length).toBe(5);
expect(network.syncPush.mock.calls.length).toBe(1);
expect(network.syncPush.mock.calls[0][0].length).toBe(5);
expect(session.syncPull.mock.calls).toEqual([]);
expect(network.syncPull.mock.calls).toEqual([]);
});
it('Resources removed on DB change', async () => {
@@ -448,10 +449,10 @@ describe('Integration tests for creating Resources and pushing', () => {
expect(resource.removed).toBe(false);
expect(updatedResource.removed).toBe(true);
expect(session.syncPush.mock.calls.length).toBe(1);
expect(session.syncPush.mock.calls[0][0].length).toBe(5);
expect(network.syncPush.mock.calls.length).toBe(1);
expect(network.syncPush.mock.calls[0][0].length).toBe(5);
expect(session.syncPull.mock.calls).toEqual([]);
expect(network.syncPull.mock.calls).toEqual([]);
});
});
@@ -508,7 +509,7 @@ async function _setSessionData() {
async function _setupSessionMocks() {
const resourceGroups = {};
session.syncCreateResourceGroup = jest.fn((parentId, name, _) => {
network.syncCreateResourceGroup = jest.fn((parentId, name, _) => {
const id = `rg_${Object.keys(resourceGroups).length + 1}`;
// Generate a public key and use a symmetric equal to it's Id for
@@ -530,7 +531,7 @@ async function _setupSessionMocks() {
return resourceGroups[id];
});
session.syncGetResourceGroup = jest.fn(id => {
network.syncGetResourceGroup = jest.fn(id => {
if (resourceGroups[id]) {
return resourceGroups[id];
}
@@ -540,14 +541,14 @@ async function _setupSessionMocks() {
throw err;
});
session.syncPull = jest.fn(body => ({
network.syncPull = jest.fn(body => ({
updatedResources: [],
createdResources: [],
idsToPush: [],
idsToRemove: [],
}));
session.syncPush = jest.fn(body => ({
network.syncPush = jest.fn(body => ({
conflicts: [],
updated: [],
created: [],
@@ -1,11 +1,19 @@
import * as db from '../common/database';
import * as models from '../models';
import * as crypt from './crypt';
import * as session from './session';
import * as store from './storage';
import * as misc from '../common/misc';
import Logger from './logger';
import * as zlib from 'zlib';
import {
syncCreateResourceGroup,
syncFixDupes,
syncGetResourceGroup,
syncPull,
syncPush,
syncResetData,
} from './network';
import * as crypt from '../account/crypt';
import * as session from '../account/session';
export const START_DELAY = 1e3;
export const PULL_PERIOD = 15e3;
@@ -32,7 +40,19 @@ let _writeChangesInterval = null;
let _pendingDBChanges = {};
let _isInitialized = false;
// Used to mark whether or not the new sync system is enabled
let _disabledForSession = false;
export function disableForSession() {
_disabledForSession = true;
}
export async function init() {
if (_disabledForSession) {
logger.debug('Legacy sync is disabled for current session');
return;
}
if (_isInitialized) {
logger.debug('Already enabled');
return;
@@ -42,8 +62,8 @@ export async function init() {
_isInitialized = true;
db.onChange(async changes => {
// To help prevent bugs, put Workspaces first
const sortedChanges = changes.sort(
([event, doc, fromSync]) => (doc.type === models.workspace.type ? 1 : -1),
const sortedChanges = changes.sort(([event, doc, fromSync]) =>
doc.type === models.workspace.type ? 1 : -1,
);
for (const [event, doc, fromSync] of sortedChanges) {
@@ -154,7 +174,7 @@ export async function fixDuplicateResourceGroups() {
// Fix duplicates
const ids = resources.map(r => r.resourceGroupId);
const { deleteResourceGroupIds } = await session.syncFixDupes(ids);
const { deleteResourceGroupIds } = await syncFixDupes(ids);
for (const idToDelete of deleteResourceGroupIds) {
await store.removeResourceGroup(idToDelete);
@@ -187,6 +207,11 @@ export async function writePendingChanges() {
}
export async function push(resourceGroupId = null) {
if (_disabledForSession) {
logger.debug('Legacy sync is disabled for current session');
return;
}
if (!session.isLoggedIn()) {
return;
}
@@ -199,7 +224,7 @@ export async function push(resourceGroupId = null) {
}
if (!allDirtyResources.length) {
logger.debug('No changes to push');
// No changes to push
return;
}
@@ -223,7 +248,7 @@ export async function push(resourceGroupId = null) {
let responseBody;
try {
responseBody = await session.syncPush(dirtyResources);
responseBody = await syncPush(dirtyResources);
} catch (e) {
logger.error('Failed to push changes', e);
return;
@@ -298,6 +323,11 @@ export async function push(resourceGroupId = null) {
}
export async function pull(resourceGroupId = null, createMissingResources = true) {
if (_disabledForSession) {
logger.debug('Legacy sync is disabled for current session');
return;
}
if (!session.isLoggedIn()) {
return;
}
@@ -337,11 +367,13 @@ export async function pull(resourceGroupId = null, createMissingResources = true
blacklist: blacklistedResourceGroupIds,
};
logger.debug(`Pulling with ${resources.length} resources`);
if (resources.length) {
logger.debug(`Pulling with ${resources.length} resources`);
}
let responseBody;
try {
responseBody = await session.syncPull(body);
responseBody = await syncPull(body);
} catch (e) {
logger.error('Failed to sync changes', e, body);
return;
@@ -535,7 +567,7 @@ export async function resetLocalData() {
}
export async function resetRemoteData() {
await session.syncResetData();
await syncResetData();
}
// ~~~~~~~ //
@@ -587,7 +619,7 @@ export async function fetchResourceGroup(resourceGroupId, invalidateCache = fals
if (!resourceGroup) {
try {
resourceGroup = await session.syncGetResourceGroup(resourceGroupId);
resourceGroup = await syncGetResourceGroup(resourceGroupId);
} catch (e) {
if (e.statusCode === 404) {
await store.removeResourceGroup(resourceGroupId);
@@ -716,7 +748,7 @@ export async function createResourceGroup(parentId, name) {
// Create the new ResourceGroup
let resourceGroup;
try {
resourceGroup = await session.syncCreateResourceGroup(parentId, name, encRGSymmetricJWK);
resourceGroup = await syncCreateResourceGroup(parentId, name, encRGSymmetricJWK);
} catch (e) {
logger.error(`Failed to create ResourceGroup: ${e}`);
throw e;
@@ -802,8 +834,8 @@ export async function getOrCreateAllActiveResources(resourceGroupId = null) {
}
// Make sure Workspace is first, because the loop below depends on it
const modelTypes = Object.keys(WHITE_LIST).sort(
(a, b) => (a.type === models.workspace.type ? 1 : -1),
const modelTypes = Object.keys(WHITE_LIST).sort((a, b) =>
a.type === models.workspace.type ? 1 : -1,
);
let created = 0;
@@ -0,0 +1,76 @@
import * as fetch from '../account/fetch';
import * as session from '../account/session';
import * as crypt from '../account/crypt';
export async function syncCreateResourceGroup(parentResourceId, name, encSymmetricKey) {
return fetch.post(
'/api/resource_groups',
{
parentResourceId,
name,
encSymmetricKey,
},
session.getCurrentSessionId(),
);
}
export async function syncGetResourceGroup(id) {
return fetch.get(`/api/resource_groups/${id}`, session.getCurrentSessionId());
}
export async function syncPull(body) {
return fetch.post('/sync/pull', body, session.getCurrentSessionId(), true);
}
export async function syncPush(body) {
return fetch.post('/sync/push', body, session.getCurrentSessionId(), true);
}
export async function syncResetData() {
return fetch.post('/auth/reset', session.getCurrentSessionId());
}
export async function syncFixDupes(resourceGroupIds) {
return fetch.post('/sync/fix-dupes', { ids: resourceGroupIds }, session.getCurrentSessionId());
}
export async function unshareWithAllTeams(resourceGroupId) {
return fetch.put(
`/api/resource_groups/${resourceGroupId}/unshare`,
session.getCurrentSessionId(),
);
}
export async function shareWithTeam(resourceGroupId, teamId) {
// Ask the server what we need to do to invite the member
const instructions = await fetch.post(
`/api/resource_groups/${resourceGroupId}/share-a`,
{
teamId,
},
session.getCurrentSessionId(),
);
const privateKeyJWK = session.getPrivateKey();
const resourceGroupSymmetricKey = crypt.decryptRSAWithJWK(
privateKeyJWK,
instructions.encSymmetricKey,
);
// Build the invite data request
const newKeys = {};
for (const accountId of Object.keys(instructions.keys)) {
const accountPublicKeyJWK = JSON.parse(instructions.keys[accountId]);
newKeys[accountId] = crypt.encryptRSAWithJWK(accountPublicKeyJWK, resourceGroupSymmetricKey);
}
// Actually share it with the team
await fetch.post(
`/api/resource_groups/${resourceGroupId}/share-b`,
{
teamId,
keys: newKeys,
},
session.getCurrentSessionId(),
);
}
@@ -1,33 +0,0 @@
import * as crypt from '../crypt';
import { globalBeforeEach } from '../../__jest__/before-each';
describe('deriveKey()', () => {
beforeEach(globalBeforeEach);
it('derives a key properly', async () => {
const result = await crypt.deriveKey('Password', 'email', 'salt');
const expected = '';
expect(result).toBe(expected);
});
});
describe('encryptRSA', () => {
beforeEach(globalBeforeEach);
it('encrypts and decrypts', () => {
const resultEncrypted = crypt.encryptAES('rawkey', 'Hello World!', 'additional data');
const resultDecrypted = crypt.decryptAES('rawkey', resultEncrypted);
const expectedEncrypted = {
ad: '6164646974696f6e616c2064617461',
d: '48656c6c6f253230576f726c6421',
iv: '616161616161616161616161',
t: '746167',
};
const expectedDecrypted = 'Hello World!';
expect(resultEncrypted).toEqual(expectedEncrypted);
expect(resultDecrypted).toEqual(expectedDecrypted);
});
});
@@ -0,0 +1,70 @@
import { diff, __internal } from '../diff';
describe('diff()', () => {
it('creates block map', () => {
const result = __internal.getBlockMap('Hello World!!', 3);
expect(result).toEqual({
dbc2d1fed0dc37a70aea0f376958c802eddc0559: [
{
start: 0,
len: 3,
hash: 'dbc2d1fed0dc37a70aea0f376958c802eddc0559',
},
],
'263b8ac41bd2ffb775af535c54d8b0a0e6b9e743': [
{
start: 3,
len: 3,
hash: '263b8ac41bd2ffb775af535c54d8b0a0e6b9e743',
},
],
'3603dd999f7dd952041d3bdb27f74e511cfd6b2a': [
{
start: 6,
len: 3,
hash: '3603dd999f7dd952041d3bdb27f74e511cfd6b2a',
},
],
c20d168802bbae1c84f90b9b0495e0d918da3aea: [
{
start: 9,
len: 3,
hash: 'c20d168802bbae1c84f90b9b0495e0d918da3aea',
},
],
'0ab8318acaf6e678dd02e2b5c343ed41111b393d': [
{
start: 12,
len: 1,
hash: '0ab8318acaf6e678dd02e2b5c343ed41111b393d',
},
],
});
});
it('creates operations', () => {
// hel|lo |Wor|ld!|!
const result = diff('Hello World!!', 'Hello there World!!', 3);
expect(result).toEqual([
{ type: 'COPY', start: 0, len: 6 },
{ type: 'INSERT', content: 'there ' },
{ type: 'COPY', start: 6, len: 7 },
]);
});
it('creates operations 2', () => {
const result2 = diff(
'Hello, this is a pretty long sentence about not much at all.',
'Hello, this is a pretty short sentence.',
2,
);
expect(result2).toEqual([
{ len: 24, start: 0, type: 'COPY' },
{ content: 'shor', type: 'INSERT' },
{ len: 2, start: 42, type: 'COPY' },
{ content: 's', type: 'INSERT' },
{ len: 7, start: 30, type: 'COPY' },
{ content: '.', type: 'INSERT' },
]);
});
});
@@ -0,0 +1,38 @@
import { diff } from '../diff';
import { patch } from '../patch';
describe('patch()', () => {
it('works on many examples', () => {
const things = [
[
'Hello, this is a pretty long sentence about not much at all.',
'Hello, this is a pretty short sentence.',
],
['xxxxxxxxxxyyyyyyyyyyyyyyyyzzzzzzzzzzzzzzzz', 'abc'],
['xyz', 'xyz'],
];
for (const thing of things) {
// Test 100 chunk sizes
for (let i = 1; i < 5; i++) {
// Test both directions
for (let j = 0; j < 2; j++) {
let a, b;
if (j === 0) {
a = thing[0];
b = thing[1];
} else {
a = thing[1];
b = thing[0];
}
// Diff the result
const diffResult = diff(a, b, i * 5);
// Use the diff to patch it and make sure it equals the same
expect(patch(a, diffResult)).toBe(b);
}
}
}
});
});
@@ -0,0 +1,130 @@
// @flow
import crypto from 'crypto';
type InsertOperation = {|
type: 'INSERT',
content: string,
|};
type CopyOperation = {|
type: 'COPY',
start: number,
len: number,
|};
export type Operation = InsertOperation | CopyOperation;
type Block = {|
start: number,
len: number,
hash: string,
|};
export function diff(source: string, target: string, blockSize: number): Array<Operation> {
const operations: Array<Operation> = [];
const sourceBlockMap = getBlockMap(source, blockSize);
// Iterate over source blocks in order and match them to target
let lastTargetMatch = 0;
for (let targetPosition = 0; targetPosition < target.length; ) {
const targetBlock = getBlock(target, targetPosition, blockSize);
const sourceBlocks = sourceBlockMap[targetBlock.hash] || [];
if (sourceBlocks.length === 0) {
targetPosition++;
continue;
}
// TODO: Try all blocks
const sourceBlock = sourceBlocks[0];
// Try to match as far as possible
let sourceIndex = sourceBlock.start + sourceBlock.len;
let targetIndex = targetBlock.start + targetBlock.len;
while (targetIndex < target.length && sourceIndex < source.length) {
if (source[sourceIndex] === target[targetIndex]) {
targetIndex++;
sourceIndex++;
} else {
break;
}
}
while (
source[sourceIndex++] === target[targetIndex] &&
targetIndex < target.length &&
sourceIndex < source.length
) {
targetIndex++;
}
sourceIndex--;
// Found unknown bytes, INSERT them
if (targetBlock.start > lastTargetMatch) {
operations.push({
type: 'INSERT',
content: target.slice(lastTargetMatch, targetBlock.start),
});
}
// Source block found in target
operations.push({
type: 'COPY',
start: sourceBlock.start,
len: sourceIndex - sourceBlock.start,
});
targetPosition = lastTargetMatch = targetIndex;
}
// Add the target suffix if there's still some left
if (lastTargetMatch < target.length) {
operations.push({
type: 'INSERT',
content: target.slice(lastTargetMatch),
});
}
return operations;
}
function getBlock(value: string, start: number, blockSize: number): Block {
if (start >= value.length) {
throw new Error('Invalid block index');
}
const blockSlice = value.slice(start, start + blockSize);
return {
start,
len: blockSlice.length,
hash: crypto
.createHash('sha1')
.update(blockSlice)
.digest('hex'),
};
}
function getBlockMap(value: string, blockSize: number): { [string]: Block } {
const map = {};
for (let i = 0; i < value.length; ) {
const block = getBlock(value, i, blockSize);
if (map[block.hash]) {
map[block.hash].push(block);
} else {
map[block.hash] = [block];
}
i += block.len;
}
return map;
}
export const __internal = {
getBlockMap,
};
@@ -0,0 +1,16 @@
// @flow
import type { Operation } from './diff';
export function patch(a: string, operations: Array<Operation>): string {
let result = '';
for (const op of operations) {
if (op.type === 'COPY') {
result += a.slice(op.start, op.start + op.len);
} else if (op.type === 'INSERT') {
result += op.content;
}
}
return result;
}
@@ -0,0 +1,40 @@
import { deterministicStringify } from '../deterministicStringify';
describe('deterministicStringify()', () => {
it('sorts object keys', () => {
const result = deterministicStringify({
c: 'c',
a: 'a',
b: 'b',
});
expect(result).toBe('{"a":"a","b":"b","c":"c"}');
});
it('works recursively', () => {
const result = deterministicStringify({
arr: [{ b: 'b', a: 'a' }],
obj: {
obj2: { b: 'b', a: 'a' },
},
});
expect(result).toBe('{"arr":[{"a":"a","b":"b"}],"obj":{"obj2":{"a":"a","b":"b"}}}');
});
it('works with strange types', () => {
const sDate = deterministicStringify(new Date(1541178019555));
expect(sDate).toBe('"2018-11-02T17:00:19.555Z"');
const sNull = deterministicStringify(null);
expect(sNull).toBe('null');
});
it('skips non-json types', () => {
const sFunc = deterministicStringify({
a: [0, () => null],
fn: () => null,
});
expect(sFunc).toBe('{"a":[0]}');
});
});
@@ -0,0 +1,30 @@
// @flow
export function deterministicStringify(value: any): string {
const t = Object.prototype.toString.call(value);
if (t === '[object Object]') {
const pairs = [];
for (const key of Object.keys(value).sort()) {
const k = deterministicStringify(key);
const v = deterministicStringify(value[key]);
if (v !== '' && k !== '') {
pairs.push(`${k}:${v}`);
}
}
return `{${pairs.join(',')}}`;
} else if (t === '[object Array]') {
const items = [];
for (const v of value) {
const vStr = deterministicStringify(v);
if (vStr !== '') {
items.push(vStr);
}
}
return `[${items.join(',')}]`;
}
const str = JSON.stringify(value);
// Only return valid stringifyable things
return str === undefined ? '' : str;
}
@@ -0,0 +1,66 @@
import Store from '../index';
import MemoryDriver from '../drivers/memory-driver';
describe('store', () => {
for (const Driver of [MemoryDriver]) {
describe(Driver.name, () => {
it('supports CRUD operations', async () => {
const s = new Store(new Driver());
expect(await s.hasItem('404')).toBe(false);
expect(await s.getItem('404')).toBe(null);
expect(await s.setItem('foo', 'bar')).toBe(undefined);
expect(await s.getItem('foo')).toBe('bar');
expect(await s.setItem('null', null)).toBe(undefined);
expect(await s.getItem('null')).toBe(null);
expect(await s.setItem('obj', { foo: 'bar' })).toBe(undefined);
expect(await s.getItem('obj')).toEqual({ foo: 'bar' });
expect(await s.removeItem('foo')).toBe(undefined);
expect(await s.hasItem('foo')).toBe(false);
expect(await s.getItem('foo')).toBe(null);
});
it('clears all values', async () => {
const s = new Store(new Driver());
await s.setItem('a', 'aaa');
await s.setItem('b', 'bbb');
await s.clear();
expect(await s.hasItem('a')).toBe(false);
expect(await s.hasItem('b')).toBe(false);
});
it('stores buffers directly', async () => {
const s = new Store(new Driver());
await s.setItem('buff', Buffer.from(`{"hi": "there"}`, 'utf8'));
await s.setItem('json', { hi: 'there' });
expect(await s.getItem('buff')).toEqual({ hi: 'there' });
expect(await s.getItem('json')).toEqual({ hi: 'there' });
expect((await s._driver.getItem('buff')).toString('utf8')).toEqual(`{"hi": "there"}`);
expect((await s._driver.getItem('json')).toString('utf8')).toEqual(`{\n "hi": "there"\n}`);
});
});
}
it('supports hooks', async () => {
const s = new Store(new MemoryDriver(), [
{
// Just some dumb hooks to test with
read: (ext, buff) => Buffer.from(buff.toString('utf8').replace('WORLD', 'World')),
write: (ext, buff) => Buffer.from(buff.toString('utf8').replace('World', 'WORLD')),
},
]);
await s.setItem('foo', { Hello: 'World!' });
expect((await s._driver.getItem('foo')).toString('utf8')).toBe('{\n "Hello": "WORLD!"\n}');
expect(await s.getItem('foo')).toEqual({ Hello: 'World!' });
});
});
@@ -0,0 +1,17 @@
// @flow
export interface BaseDriver {
constructor(config: { [string]: any }): void;
hasItem(key: string): Promise<boolean>;
setItem(key: string, value: Buffer): Promise<void>;
getItem(key: string): Promise<Buffer | null>;
removeItem(key: string): Promise<void>;
keys(prefix: string, recursive: boolean): Promise<Array<string>>;
clear(): Promise<void>;
}
@@ -0,0 +1,150 @@
// @flow
import fs from 'fs';
import path from 'path';
import mkdirp from 'mkdirp';
import type { BaseDriver } from './base';
export default class FileSystemDriver implements BaseDriver {
_directory: string;
constructor(config: { directory: string }) {
this._directory = config.directory;
console.log(`[FileSystemDriver] Initialized in "${this._directory}"`);
}
async hasItem(key: string): Promise<boolean> {
return new Promise((resolve, reject) => {
fs.stat(this._getKeyPath(key), (err, result) => {
if (err && err.code === 'ENOENT') {
resolve(false);
} else if (err) {
reject(err);
} else {
resolve(true);
}
});
});
}
setItem(key: string, value: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
const finalPath = this._getKeyPath(key);
// Temp path contains randomness to avoid race-condition collisions. This
// doesn't actually avoid race conditions but at least it won't fail.
const tmpPath = `${finalPath}.${Math.random()}.tmp`;
// This method implements atomic writes by first writing to a temporary
// file (non-atomic) then renaming the file to the final value (atomic)
fs.writeFile(tmpPath, value, 'utf8', err => {
if (err) {
return reject(err);
}
fs.rename(tmpPath, finalPath, err => {
if (err) {
return reject(err);
}
resolve();
});
});
});
}
getItem(key: string): Promise<Buffer | null> {
return new Promise((resolve, reject) => {
fs.readFile(this._getKeyPath(key), (err, data) => {
if (err && err.code === 'ENOENT') {
resolve(null);
} else if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
removeItem(key: string): Promise<void> {
return new Promise((resolve, reject) => {
fs.unlink(this._getKeyPath(key), err => {
if (err && err.code === 'ENOENT') {
resolve();
} else if (err) {
reject(err);
} else {
resolve();
}
});
});
}
clear(): Promise<void> {
return new Promise((resolve, reject) => {
fs.readdir(this._directory, (err, names) => {
if (err) {
return reject(err);
}
for (const name of names) {
fs.unlinkSync(this._getKeyPath(name));
}
resolve();
});
});
}
async keys(prefix: string, recursive: boolean): Promise<Array<string>> {
const next = dir => {
return new Promise(async (resolve, reject) => {
let keys: Array<string> = [];
let names = [];
try {
names = fs.readdirSync(dir);
} catch (err) {
if (err.code !== 'ENOENT') {
reject(err);
}
}
for (const name of names) {
if (name.indexOf('.') === 0) {
// Skip any non-vcs files
continue;
}
const p = path.join(dir, name);
const isDir = fs.statSync(p).isDirectory();
if (isDir && recursive) {
const more = await next(p);
keys = [...keys, ...more];
} else if (!recursive && isDir) {
keys.push(p);
} else if (!isDir) {
keys.push(p);
}
}
resolve(keys);
});
};
const rawKeys = await next(this._getKeyPath(prefix));
const keys = [];
for (const rawKey of rawKeys) {
keys.push(rawKey.substring(this._directory.length));
}
return keys;
}
_getKeyPath(key: string): string {
const p = path.join(this._directory, key);
// Create base directory
mkdirp.sync(path.dirname(p));
return p;
}
}
@@ -0,0 +1,63 @@
// @flow
import type { BaseDriver } from './base';
export default class MemoryDriver implements BaseDriver {
_db: { [string]: Buffer };
constructor() {
this._init();
}
async hasItem(key: string): Promise<boolean> {
return this._db[String(key)] instanceof Buffer;
}
async setItem(key: string, value: Buffer): Promise<void> {
this._db[String(key)] = value;
}
async getItem(key: string): Promise<Buffer | null> {
let value = null;
if (await this.hasItem(key)) {
value = this._db[key];
}
return value;
}
async removeItem(key: string): Promise<void> {
delete this._db[String(key)];
}
async clear(): Promise<void> {
this._init();
}
async keys(prefix: string, recursive: boolean): Promise<Array<string>> {
const keys = [];
const baseLevels = prefix.split('/').length;
for (const key of Object.keys(this._db)) {
if (key.indexOf(prefix) !== 0) {
continue;
}
const levels = key.split('/').length;
const isOnSameLevel = levels === baseLevels;
if (!recursive && !isOnSameLevel) {
continue;
}
keys.push(key);
}
return keys;
}
_init() {
this._db = {};
}
}
@@ -0,0 +1,20 @@
import hook from '../compress';
describe('compress hook', () => {
it('compresses non-extension keys', async () => {
const compressed = await hook.write('', 'hello');
const uncompressed = await hook.read('', compressed);
expect(uncompressed.toString()).toBe('hello');
});
it('writes raw data for extensions', async () => {
const compressed = await hook.write('.json', 'hello');
expect(compressed.toString('base64')).toBe('hello');
});
it('reads compressed data', async () => {
const data = Buffer.from('hello', 'utf8');
const compressed = await hook.read('.json', data);
expect(compressed.toString('utf8')).toBe('hello');
});
});
@@ -0,0 +1,40 @@
// @flow
import type { HookFn } from '../index';
import zlib from 'zlib';
const read: HookFn = async function read(extension: string, value: Buffer): Promise<Buffer> {
if (extension) {
return value;
}
return new Promise((resolve, reject) => {
zlib.gunzip(value, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
};
const write: HookFn = async function read(extension: string, value: Buffer): Promise<Buffer> {
if (extension) {
return value;
}
return new Promise((resolve, reject) => {
zlib.gzip(value, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
};
export default {
read,
write,
};
@@ -0,0 +1,101 @@
// @flow
import path from 'path';
import type { BaseDriver } from './drivers/base';
// Can't really make this any more specific unfortunately
type JSONValue = any;
export type HookFn = (extension: string, value: Buffer) => Promise<Buffer>;
export type Hook = { read: HookFn, write: HookFn };
export default class Store {
_driver: BaseDriver;
_hooks: Array<Hook>;
constructor(driver: BaseDriver, hooks?: Array<Hook>) {
this._driver = driver;
this._hooks = hooks || [];
}
async hasItem(key: string): Promise<boolean> {
return this._driver.hasItem(key);
}
async setItem(key: string, value: JSONValue | Buffer): Promise<void> {
const ext = path.extname(key);
let serializedValue;
try {
serializedValue = await this._serialize(ext, value);
} catch (err) {
throw new Error(`Failed to serialize key=${key} err=${err}`);
}
return this._driver.setItem(key, serializedValue);
}
async setItemRaw(key: string, value: Buffer): Promise<void> {
return this._driver.setItem(key, value);
}
async getItem(key: string): Promise<JSONValue | null> {
const rawValue = await this.getItemRaw(key);
if (rawValue === null) {
return null;
}
const ext = path.extname(key);
let value;
try {
// Without the `await` here, the catch won't get called
value = await this._deserialize(ext, rawValue);
} catch (err) {
console.log('Failed to deserialize', rawValue.toString('base64'));
throw new Error(`Failed to deserialize key=${key} err=${err}`);
}
return value;
}
async getItemRaw(key: string): Promise<Buffer | null> {
return this._driver.getItem(key);
}
async removeItem(key: string): Promise<void> {
return this._driver.removeItem(key);
}
async keys(prefix: string, recursive: boolean = true): Promise<Array<string>> {
return this._driver.keys(prefix, recursive);
}
async clear(): Promise<void> {
return this._driver.clear();
}
async _serialize(ext: string, raw: JSONValue | Buffer): Promise<Buffer> {
let buff = raw instanceof Buffer ? raw : Buffer.from(JSON.stringify(raw, null, 2), 'utf8');
for (const hook of this._hooks) {
if (!hook.write) {
continue;
}
buff = await hook.write(ext, buff);
}
return buff;
}
async _deserialize(ext: string, value: Buffer): Promise<JSONValue | null> {
for (const hook of this._hooks) {
if (!hook.read) {
continue;
}
value = await hook.read(ext, value);
}
return JSON.parse(value.toString('utf8'));
}
}
+100
View File
@@ -0,0 +1,100 @@
// @flow
export type Team = {
id: string,
name: string,
};
export type Project = {
id: string,
name: string,
rootDocumentId: string,
};
export type DocumentKey = string;
export type BlobId = string;
export type Head = {|
branch: string,
|};
export type SnapshotStateEntry = {|
key: DocumentKey,
blob: BlobId,
name: string,
|};
export type SnapshotState = Array<SnapshotStateEntry>;
export type SnapshotStateMap = { [DocumentKey]: SnapshotStateEntry };
export type SnapshotId = string;
export type Snapshot = {|
id: SnapshotId,
created: Date,
parent: string,
author: string,
name: string,
description: string,
state: Array<SnapshotStateEntry>,
|};
export type Branch = {|
name: string,
created: Date,
modified: Date,
snapshots: Array<string>,
|};
export type StageEntryDelete = {|
deleted: true,
key: string,
name: string,
blobId: string,
|};
export type StageEntryAdd = {|
added: true,
key: string,
name: string,
blobId: string,
blobContent: string,
|};
export type StageEntryModify = {|
modified: true,
key: string,
name: string,
blobId: string,
blobContent: string,
|};
export type StageEntry = StageEntryDelete | StageEntryAdd | StageEntryModify;
export type MergeConflict = {|
name: string,
key: DocumentKey,
message: string,
mineBlob: BlobId | null,
theirsBlob: BlobId | null,
choose: BlobId | null,
|};
export type Stage = {
[DocumentKey]: StageEntry,
};
export type StatusCandidate = {|
key: DocumentKey,
name: string,
document: Object,
|};
export type StatusCandidateMap = { [DocumentKey]: StatusCandidate };
export type Status = {|
key: string,
stage: Stage,
unstaged: {
[DocumentKey]: StageEntry,
},
|};
@@ -0,0 +1,678 @@
import VCS from '../index';
import MemoryDriver from '../../store/drivers/memory-driver';
function newDoc(id) {
return { id };
}
async function vcs(branch) {
const v = new VCS(new MemoryDriver());
await v.switchProject('workspace_1', 'Test Workspace');
await v.checkout([], branch);
return v;
}
describe('VCS', () => {
beforeEach(() => {
let ts = 1000000000000;
Date.now = jest.fn(() => ts++);
});
describe('status()', () => {
it('returns status with no commits', async () => {
const v = await vcs('master');
const status = await v.status(
[
{
key: 'foo',
name: 'Foo',
document: newDoc('bar'),
},
{
key: 'baz',
name: 'Baz',
document: newDoc('qux'),
},
],
{},
);
expect(status).toEqual({
key: '0cffac636df909fb4f8e9a25570e5012846b46fd',
stage: {},
unstaged: {
foo: {
added: true,
key: 'foo',
blobContent: '{"id":"bar"}',
blobId: 'b933e3dbd1d218e2763de8e3ece6147527d046af',
name: 'Foo',
},
baz: {
added: true,
key: 'baz',
blobContent: '{"id":"qux"}',
blobId: 'dc61fb5cb183286293dc7f0e8499b4e1e09eef05',
name: 'Baz',
},
},
});
});
it('returns add/modify/delete operations', async () => {
const v = await vcs('master');
const status1 = await v.status(
[
{ key: 'a', name: 'A', document: newDoc('aaa') },
{ key: 'b', name: 'B', document: newDoc('bbb') },
{ key: 'c', name: 'C', document: newDoc('ccc') },
],
{},
);
expect(Object.keys(status1.unstaged)).toEqual(['a', 'b', 'c']);
await v.stage(status1.stage, [
status1.unstaged['a'],
status1.unstaged['b'],
status1.unstaged['c'],
]);
await v.takeSnapshot(status1.stage, 'Add a/b/c');
const history = await v.getHistory();
expect(history.length).toBe(1);
expect(history).toEqual([
{
created: expect.any(Date),
description: '',
author: '',
id: 'a5fb7270c060c0387967c6db725fdf07e9594911',
name: 'Add a/b/c',
parent: '0000000000000000000000000000000000000000',
state: [
{
blob: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
{
blob: '8ce1aded8fa999d1c5632ff993b56dd9aa1f4880',
key: 'b',
name: 'B',
},
{
blob: 'ce29c03e80866a215004d55f160a9a7b510ceacb',
key: 'c',
name: 'C',
},
],
},
]);
// Should get every operation type
const status = await v.status(
[
{ key: 'notA', name: 'Not A', document: newDoc('aaa') },
{ key: 'b', name: 'B', document: newDoc('bbb') },
{ key: 'c', name: 'C', document: newDoc('modified') },
{ key: 'd', name: 'D', document: newDoc('ddd') },
],
{},
);
expect(status).toEqual({
key: '80834d815272c73192291477f70dce8a7c8bc424',
stage: {},
unstaged: {
a: {
deleted: true,
blobId: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
notA: {
added: true,
key: 'notA',
name: 'Not A',
blobId: '210be55f7998bb55805f9d65c5345103e7957929',
blobContent: '{"id":"aaa"}',
},
c: {
modified: true,
key: 'c',
name: 'C',
blobId: 'c32090250612936632fa4a3127507aa0694ba375',
blobContent: '{"id":"modified"}',
},
d: {
added: true,
key: 'd',
name: 'D',
blobId: 'd4d10ce251d7184fa14587796aed6e06c549f7a4',
blobContent: '{"id":"ddd"}',
},
},
});
const newStage = await v.stage(status.stage, [
status.unstaged['a'],
status.unstaged['notA'],
status.unstaged['c'],
status.unstaged['d'],
]);
const status2 = await v.status(
[
{ key: 'notA', name: 'Not A', document: newDoc('aaa') },
{ key: 'b', name: 'B', document: newDoc('bbb') },
{ key: 'c', name: 'C', document: newDoc('modified') },
{ key: 'd', name: 'D', document: newDoc('ddd') },
],
newStage,
);
expect(status2).toEqual({
key: '4a0e8fd9ee30acb9c8ff4eb2f73e413ff7175a8c',
stage: {
a: {
deleted: true,
blobId: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
notA: {
added: true,
blobId: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'notA',
name: 'Not A',
blobContent: '{"id":"aaa"}',
},
c: {
modified: true,
blobId: 'c32090250612936632fa4a3127507aa0694ba375',
key: 'c',
name: 'C',
blobContent: '{"id":"modified"}',
},
d: {
added: true,
blobId: 'd4d10ce251d7184fa14587796aed6e06c549f7a4',
key: 'd',
name: 'D',
blobContent: '{"id":"ddd"}',
},
},
unstaged: {},
});
});
it('can appear both staged and unstaged', async () => {
const v = await vcs('master');
const status = await v.status(
[
{ key: 'a', name: 'A', document: newDoc('aaa') },
{ key: 'b', name: 'B', document: newDoc('bbb') },
],
{},
);
const stage = await v.stage(status.stage, [status.unstaged['a']]);
const status2 = await v.status(
[
{ key: 'a', name: 'A', document: newDoc('modified') },
{ key: 'b', name: 'B', document: newDoc('bbb') },
],
stage,
);
expect(status2).toEqual({
key: '886337817e3c1ff7df3fcfa7c30192eb854b45cf',
stage: {
a: {
added: true,
blobId: '210be55f7998bb55805f9d65c5345103e7957929',
name: 'A',
key: 'a',
blobContent: '{"id":"aaa"}',
},
},
unstaged: {
a: {
added: true,
blobId: 'c32090250612936632fa4a3127507aa0694ba375',
key: 'a',
name: 'A',
blobContent: '{"id":"modified"}',
},
b: {
added: true,
blobId: '8ce1aded8fa999d1c5632ff993b56dd9aa1f4880',
name: 'B',
key: 'b',
blobContent: '{"id":"bbb"}',
},
},
});
});
it('should not show committed entities', async () => {
const v = await vcs('master');
const status = await v.status([{ key: 'foo', name: 'Foo', document: newDoc('bar') }], {});
const stage2 = await v.stage(status.stage, [status.unstaged['foo']]);
await v.takeSnapshot(stage2, 'Add foo');
const status2 = await v.status([{ key: 'foo', name: 'Foo', document: newDoc('bar') }], {});
expect(status2).toEqual({
key: 'ca455b43c1e992812d81032e79e037a8db85fa8b',
stage: {},
unstaged: {},
});
});
});
describe('stage()', () => {
it('stages entity', async () => {
const v = await vcs('master');
const status = await v.status(
[
{ key: 'foo', name: 'Foo', document: newDoc('bar') },
{ key: 'baz', name: 'Baz', document: newDoc('qux') },
],
{},
);
const stage = await v.stage(status.stage, [status.unstaged['foo']]);
expect(stage).toEqual({
foo: {
key: 'foo',
name: 'Foo',
blobContent: '{"id":"bar"}',
blobId: 'b933e3dbd1d218e2763de8e3ece6147527d046af',
added: true,
},
});
const status2 = await v.status(
[
{ key: 'foo', name: 'Foo', document: newDoc('bar') },
{ key: 'baz', name: 'Baz', document: newDoc('qux') },
],
stage,
);
expect(status2).toEqual({
key: '19c281e6bbbebec0143491bf14f9a013a4114125',
stage: {
foo: {
name: 'Foo',
key: 'foo',
blobContent: '{"id":"bar"}',
blobId: 'b933e3dbd1d218e2763de8e3ece6147527d046af',
added: true,
},
},
unstaged: {
baz: {
key: 'baz',
name: 'Baz',
blobContent: '{"id":"qux"}',
blobId: 'dc61fb5cb183286293dc7f0e8499b4e1e09eef05',
added: true,
},
},
});
});
});
describe('takeSnapshot()', () => {
it('commits basic entity', async () => {
const v = await vcs('master');
const status = await v.status([{ key: 'foo', name: 'Foo', document: newDoc('bar') }], {});
const stage = await v.stage(status.stage, [status.unstaged['foo']]);
await v.takeSnapshot(stage, 'Add foo');
const history = await v.getHistory();
expect(history).toEqual([
{
id: '24b4888f4cecc5a1ac272eb1641cb5aa102c1675',
author: '',
created: expect.any(Date),
name: 'Add foo',
description: '',
parent: '0000000000000000000000000000000000000000',
state: [
{
blob: 'b933e3dbd1d218e2763de8e3ece6147527d046af',
key: 'foo',
name: 'Foo',
},
],
},
]);
});
it('commits deleted entity', async () => {
const v = await vcs('master');
const status = await v.status([{ key: 'foo', name: 'Foo', document: newDoc('bar') }], {});
const stage = await v.stage(status.stage, [status.unstaged['foo']]);
await v.takeSnapshot(stage, 'Add foo');
const history = await v.getHistory();
expect(history).toEqual([
{
id: '365c4341f6d57e18994df2429387b78f2c9a31cb',
author: '',
created: expect.any(Date),
name: 'Add foo',
description: '',
parent: '0000000000000000000000000000000000000000',
state: [
{
blob: 'b933e3dbd1d218e2763de8e3ece6147527d046af',
key: 'foo',
name: 'Foo',
},
],
},
]);
const status2 = await v.status([], {});
const stage2 = await v.stage(status2.stage, [status2.unstaged['foo']]);
await v.takeSnapshot(stage2, 'Delete foo');
const history2 = await v.getHistory();
expect(history2).toEqual([
{
id: '365c4341f6d57e18994df2429387b78f2c9a31cb',
author: '',
created: expect.any(Date),
name: 'Add foo',
description: '',
parent: '0000000000000000000000000000000000000000',
state: [
{
blob: 'b933e3dbd1d218e2763de8e3ece6147527d046af',
key: 'foo',
name: 'Foo',
},
],
},
{
id: '42f5b05d004f99e875a7eb3fe24ad7f94ad3e0c5',
author: '',
created: expect.any(Date),
name: 'Delete foo',
description: '',
parent: '365c4341f6d57e18994df2429387b78f2c9a31cb',
state: [],
},
]);
});
});
describe('getBranches()', () => {
it('lists branches', async () => {
const v = await vcs('master');
await v.checkout([], 'branch-1');
await v.checkout([], 'branch-2');
const branches = await v.getBranches();
expect(branches).toEqual(['master', 'branch-1', 'branch-2']);
});
});
describe('removeBranch()', () => {
it('cannot remove empty branch', async () => {
const v = await vcs('master');
let didError = false;
try {
await v.removeBranch();
} catch (err) {
didError = true;
}
expect(didError).toBe(true);
});
it('cannot remove current branch', async () => {
const v = await vcs('master');
let didError = false;
try {
await v.removeBranch('master');
} catch (err) {
didError = true;
}
expect(didError).toBe(true);
});
it('remove branch', async () => {
const v = await vcs('master');
// Add something to master
const status1 = await v.status([{ key: 'foo', name: 'Foo', document: newDoc('bar') }], {});
const stage1 = await v.stage(status1.stage, [status1.unstaged['foo']]);
await v.takeSnapshot(stage1, 'Add foo');
// Checkout branch
await v.checkout([], 'new-branch');
expect(await v.getBranches()).toEqual(['master', 'new-branch']);
// Back to master and delete other branch
await v.checkout([], 'master');
await v.removeBranch('new-branch');
expect(await v.getBranches()).toEqual(['master']);
});
});
describe('fork()', () => {
it('forks to a new branch', async () => {
const v = await vcs('master');
// Add something to master
const status1 = await v.status([{ key: 'foo', name: 'Foo', document: newDoc('bar') }], {});
const stage1 = await v.stage(status1.stage, [status1.unstaged['foo']]);
await v.takeSnapshot(stage1, 'Add foo');
// Checkout branch
await v.fork('new-branch');
await v.checkout([], 'new-branch');
const history = await v.getHistory();
expect(await v.getBranch()).toBe('new-branch');
expect(history).toEqual([
{
created: expect.any(Date),
id: 'e1afd0f5607d8da06b4360eb22d41650262f6dc5',
author: '',
parent: '0000000000000000000000000000000000000000',
name: 'Add foo',
description: '',
state: [
{
blob: 'b933e3dbd1d218e2763de8e3ece6147527d046af',
key: 'foo',
name: 'Foo',
},
],
},
]);
});
});
const name = 'merge()';
describe(name, () => {
it('performs fast-forward merge', async () => {
const v = await vcs('master');
const status1 = await v.status(
[
{ key: 'a', name: 'A', document: newDoc('aaa') },
{ key: 'b', name: 'B', document: newDoc('bbb') },
],
{},
);
const stage1 = await v.stage(status1.stage, [status1.unstaged['a'], status1.unstaged['b']]);
await v.takeSnapshot(stage1, 'Add A and B');
expect((await v.getHistory())[0].state).toEqual([
expect.objectContaining({ key: 'a' }),
expect.objectContaining({ key: 'b' }),
]);
await v.fork('feature-a');
await v.checkout([], 'feature-a');
const status2 = await v.status(
[
{ key: 'a', name: 'A', document: newDoc('aaa') },
{ key: 'b', name: 'B', document: newDoc('bbbbbbb') },
{ key: 'c', name: 'C', document: newDoc('ccc') },
],
status1.stage,
);
const stage2 = await v.stage(status2.stage, [status2.unstaged['b'], status2.unstaged['c']]);
await v.takeSnapshot(stage2, 'Add C, modify B');
expect((await v.getHistory())[1].state).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'a' }),
expect.objectContaining({ key: 'b' }),
expect.objectContaining({ key: 'c' }),
]),
);
});
it('merges even if no common root', async () => {
const v = await vcs('master');
const status1 = await v.status(
[
{ key: 'a', name: 'A', document: newDoc('aaa') },
{ key: 'b', name: 'B', document: newDoc('bbb') },
],
{},
);
const stage1 = await v.stage(status1.stage, [status1.unstaged['a'], status1.unstaged['b']]);
await v.takeSnapshot(stage1, 'message');
});
it('does something', async () => {
const v = await vcs('master');
// Add a file to master
expect(await v.getBranch()).toBe('master');
const status1 = await v.status([{ key: 'a', name: 'A', document: newDoc('aaa') }], {});
const stage = await v.stage(status1.stage, [status1.unstaged['a']]);
await v.takeSnapshot(stage, 'Add A');
expect(await v.getHistory()).toEqual([
{
id: 'c0b75a0cb27c946e376326b963ce544af08b680a',
author: '',
created: expect.any(Date),
parent: '0000000000000000000000000000000000000000',
name: 'Add A',
description: '',
state: [
{
blob: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
],
},
]);
// Checkout new branch and add file
await v.fork('new-branch');
await v.checkout([], 'new-branch');
expect(await v.getBranch()).toBe('new-branch');
const status2 = await v.status([{ key: 'b', name: 'B', document: newDoc('bbb') }], {});
const stage2 = await v.stage(status2.stage, [status2.unstaged['b']]);
await v.takeSnapshot(stage2, 'Add B');
expect(await v.getHistory()).toEqual([
{
id: 'c0b75a0cb27c946e376326b963ce544af08b680a',
author: '',
created: expect.any(Date),
parent: '0000000000000000000000000000000000000000',
name: 'Add A',
description: '',
state: [
{
blob: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
],
},
{
id: 'b0fb09c8ac80b34f94dc6751da6b5aa634682f45',
author: '',
created: expect.any(Date),
parent: 'c0b75a0cb27c946e376326b963ce544af08b680a',
name: 'Add B',
description: '',
state: [
{
blob: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
{
blob: '8ce1aded8fa999d1c5632ff993b56dd9aa1f4880',
key: 'b',
name: 'B',
},
],
},
]);
// Merge new branch back into master
await v.checkout([], 'master');
expect(await v.getBranch()).toBe('master');
await v.merge([], 'new-branch');
expect(await v.getHistory()).toEqual([
{
id: 'c0b75a0cb27c946e376326b963ce544af08b680a',
author: '',
created: expect.any(Date),
parent: '0000000000000000000000000000000000000000',
name: 'Add A',
description: '',
state: [
{
blob: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
],
},
{
id: 'b0fb09c8ac80b34f94dc6751da6b5aa634682f45',
author: '',
created: expect.any(Date),
parent: 'c0b75a0cb27c946e376326b963ce544af08b680a',
name: 'Add B',
description: '',
state: [
{
blob: '210be55f7998bb55805f9d65c5345103e7957929',
key: 'a',
name: 'A',
},
{
blob: '8ce1aded8fa999d1c5632ff993b56dd9aa1f4880',
key: 'b',
name: 'B',
},
],
},
]);
});
});
});
@@ -0,0 +1,15 @@
import * as paths from '../paths';
describe('paths', () => {
it('successes', async () => {
expect(paths.projectBase('p1')).toBe('/projects/p1/');
expect(paths.head('p1')).toBe('/projects/p1/head.json');
expect(paths.project('p1')).toBe('/projects/p1/meta.json');
expect(paths.blobs('p1')).toBe('/projects/p1/blobs/');
expect(paths.blob('p1', 'b1c1c2')).toBe('/projects/p1/blobs/b1/c1c2');
expect(paths.snapshots('p1')).toBe('/projects/p1/snapshots/');
expect(paths.snapshot('p1', 's1')).toBe('/projects/p1/snapshots/s1.json');
expect(paths.branches('p1')).toBe('/projects/p1/branches/');
expect(paths.branch('p1', 'master')).toBe('/projects/p1/branches/master.json');
});
});
@@ -0,0 +1,893 @@
import {
combinedMapKeys,
compareBranches,
generateCandidateMap,
generateSnapshotStateMap,
getRootSnapshot,
getStagable,
hashDocument,
preMergeCheck,
stateDelta,
threeWayMerge,
updateStateWithConflictResolutions,
} from '../util';
describe('util', () => {
describe('generateSnapshotStateMap()', () => {
it('generates from simple states', async () => {
const snapshot = newSnapshot(1, [
newStateEntry('doc_1', 'blob_1'),
newStateEntry('doc_2', 'blob_2'),
]);
const map = generateSnapshotStateMap(snapshot);
expect(Object.keys(map).sort()).toEqual(['doc_1', 'doc_2']);
});
it('works with duplicates', async () => {
const snapshot = newSnapshot(1, [
newStateEntry('doc_1', 'blob_1'),
newStateEntry('doc_2', 'blob_2'),
newStateEntry('doc_2', 'blob_2'),
newStateEntry('doc_2', 'blob_2'),
]);
const map = generateSnapshotStateMap(snapshot);
expect(Object.keys(map).sort()).toEqual(['doc_1', 'doc_2']);
});
it('works with null snapshots', async () => {
const map = generateSnapshotStateMap(null);
expect(map).toEqual({});
});
});
describe('generateCandidateMap()', () => {
it('generates from simple candidates', async () => {
const candidates = [
newCandidate('doc_2', 2),
newCandidate('doc_1', 1),
newCandidate('doc_2', 2),
];
const map = generateCandidateMap(candidates);
expect(Object.keys(map).sort()).toEqual(['doc_1', 'doc_2']);
});
});
describe('combineMapKeys()', () => {
it('generates from simple states', async () => {
const map1 = generateSnapshotStateMap(
newSnapshot(1, [newStateEntry('doc_1', 'blob_1'), newStateEntry('doc_2', 'blob_2')]),
);
const map2 = generateSnapshotStateMap(
newSnapshot(1, [newStateEntry('doc_4', 'blob_4'), newStateEntry('doc_1', 'blob_1')]),
);
const map3 = generateSnapshotStateMap(newSnapshot(1, []));
const keys = combinedMapKeys(map1, map2, map3);
expect(keys.sort()).toEqual(['doc_1', 'doc_2', 'doc_4']);
});
});
describe('threeWayMerge()', () => {
const A1 = { key: 'a', blob: 'a.1', name: '' };
const A2 = { key: 'a', blob: 'a.2', name: '' };
const A3 = { key: 'a', blob: 'a.3', name: '' };
const B1 = { key: 'b', blob: 'b.1', name: '' };
const B2 = { key: 'b', blob: 'b.2', name: '' };
const C1 = { key: 'c', blob: 'c.1', name: '' };
it('does nothing with empty states', () => {
const newState = threeWayMerge([], [], []);
expect(newState).toEqual({
conflicts: [],
state: [],
});
});
it('adds new object from other', () => {
const root = [A1];
const trunk = [A1];
const other = [A1, B1];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [],
state: [A1, B1],
});
});
it('removes object from trunk', () => {
const root = [A1, C1];
const trunk = [A1, C1];
const other = [A1];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [],
state: [A1],
});
});
it('removes object from both', () => {
const root = [A1, C1];
const trunk = [A1];
const other = [A1];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [],
state: [A1],
});
});
it('modifies object from other', () => {
const root = [A1];
const trunk = [A1];
const other = [A2];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [],
state: [A2],
});
});
it('modifies object from trunk', () => {
const root = [A1];
const trunk = [A2];
const other = [A1];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [],
state: [A2],
});
});
it('conflicts when both modify', () => {
const root = [A1];
const trunk = [A2];
const other = [A3];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [
{
key: A2.key,
name: A2.name,
choose: A3.blob,
mineBlob: A2.blob,
theirsBlob: A3.blob,
message: 'both modified',
},
],
state: [A2],
});
});
it('works if both add the same document', () => {
const root = [A1];
const trunk = [A1, B1];
const other = [A1, B1];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [],
state: [A1, B1],
});
});
it('conflicts if both add the same document but different', () => {
const root = [A1];
const trunk = [A1, B1];
const other = [A1, B2];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [
{
key: B1.key,
name: B1.name,
choose: B2.blob,
mineBlob: B1.blob,
theirsBlob: B2.blob,
message: 'both added',
},
],
state: [A1, B1],
});
});
it('conflicts if modified in trunk and deleted in other', () => {
const root = [A1, B1];
const trunk = [A1, B2];
const other = [A1];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [
{
key: B1.key,
name: B1.name,
choose: B2.blob,
mineBlob: B2.blob,
theirsBlob: null,
message: 'they deleted and you modified',
},
],
state: [A1, B2],
});
});
it('conflicts if modified in other and deleted in trunk', () => {
const root = [A1, B1];
const trunk = [A1];
const other = [A1, B2];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [
{
key: B1.key,
name: B1.name,
choose: B2.blob,
mineBlob: null,
theirsBlob: B2.blob,
message: 'you deleted and they modified',
},
],
state: [A1, B2],
});
});
it('works with no root state', () => {
const root = [];
const trunk = [A1, C1];
const other = [A1, B2];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [],
state: expect.arrayContaining([A1, B2, C1]),
});
});
it('works with no root state and conflict', () => {
const root = [];
const trunk = [A1];
const other = [A2];
expect(threeWayMerge(root, trunk, other)).toEqual({
conflicts: [
{
key: A1.key,
name: A1.name,
choose: A2.blob,
mineBlob: A1.blob,
theirsBlob: A2.blob,
message: 'both added',
},
],
state: [A1],
});
});
});
describe('stateDelta()', () => {
const A1 = { key: 'a', blob: 'a.1', name: '' };
const A2 = { key: 'a', blob: 'a.2', name: '' };
const B1 = { key: 'b', blob: 'b.1', name: '' };
const B2 = { key: 'b', blob: 'b.2', name: '' };
const C1 = { key: 'c', blob: 'c.1', name: '' };
it('modifies an entry', () => {
const base = [A1];
const dsrd = [A2];
expect(stateDelta(base, dsrd)).toEqual({
add: [],
update: [A2],
remove: [],
});
});
it('adds an entry', () => {
const base = [];
const dsrd = [B1];
expect(stateDelta(base, dsrd)).toEqual({
add: [B1],
update: [],
remove: [],
});
});
it('deletes an entry', () => {
const base = [A1];
const dsrd = [];
expect(stateDelta(base, dsrd)).toEqual({
add: [],
update: [],
remove: [A1],
});
});
it('complex combo', () => {
const base = [A1, B1, C1];
const dsrd = [B2, C1];
expect(stateDelta(base, dsrd)).toEqual({
add: [],
update: [B2],
remove: [A1],
});
});
it('invalid duplicate key state', () => {
const base = [A1, A2];
const dsrd = [A2];
expect(stateDelta(base, dsrd)).toEqual({
add: [],
update: [],
remove: [],
});
});
});
describe('getStagable()', () => {
const A1 = { key: 'a', blob: '1932cc749f57a77ba4a231d62559c47ee931ee2c', name: '' };
const A2 = { key: 'a', blob: 'cf2ec18c2e686b4c966725f30050120d42d4b2e5', name: '' };
const B1 = { key: 'b', blob: '05ab2ec03c7e32eff27305b4a11c573a3843fa01', name: '' };
const B2 = { key: 'b', blob: 'eb157a78ebd367a004c5a32a18d6d8c18e60ce00', name: '' };
const C1 = { key: 'c', blob: 'db7d35b96291b823e674bf23ac59e6e116b1890e', name: '' };
const DA1 = { key: 'a', document: { id: 'a', v: 1 }, name: '' };
const DA2 = { key: 'a', document: { id: 'a', v: 2 }, name: '' };
const DB1 = { key: 'b', document: { id: 'b', v: 1 }, name: '' };
const DB2 = { key: 'b', document: { id: 'b', v: 2 }, name: '' };
const DC1 = { key: 'c', document: { id: 'c', v: 1 }, name: '' };
it('works with empty state', () => {
const state = [];
const dsrd = [DA1];
expect(getStagable(state, dsrd)).toEqual([
{
blobContent: '{"id":"a","v":1}',
blobId: A1.blob,
key: 'a',
name: '',
added: true,
},
]);
});
it('stays the same', () => {
const state = [A1, B1, C1];
const dsrd = [DA1, DB1, DC1];
expect(getStagable(state, dsrd)).toEqual([]);
});
it('adds a document', () => {
const state = [A1];
const dsrd = [DA1, DB1];
expect(getStagable(state, dsrd)).toEqual([
{
key: 'b',
blobId: B1.blob,
blobContent: '{"id":"b","v":1}',
name: '',
added: true,
},
]);
});
it('modifies a document', () => {
const state = [B1];
const dsrd = [DB2];
expect(getStagable(state, dsrd)).toEqual([
{
key: 'b',
blobId: B2.blob,
blobContent: '{"id":"b","v":2}',
name: '',
modified: true,
},
]);
});
it('deletes a document', () => {
const state = [B1];
const dsrd = [];
expect(getStagable(state, dsrd)).toEqual([
{
key: 'b',
blobId: '05ab2ec03c7e32eff27305b4a11c573a3843fa01',
name: '',
deleted: true,
},
]);
});
it('does a lot of things', () => {
const state = [A1, C1];
const dsrd = [DA2, DB1];
expect(getStagable(state, dsrd)).toEqual([
{
blobContent: '{"id":"a","v":2}',
blobId: A2.blob,
key: 'a',
modified: true,
name: '',
},
{
key: 'c',
blobId: 'db7d35b96291b823e674bf23ac59e6e116b1890e',
name: '',
deleted: true,
},
{
key: 'b',
blobId: B1.blob,
blobContent: '{"id":"b","v":1}',
name: '',
added: true,
},
]);
});
});
describe('getRootSnapshot()', () => {
it('works with empty states', () => {
const a = newBranch([]);
const b = newBranch([]);
expect(getRootSnapshot(a, b)).toBeNull();
});
it('works with different states', () => {
const a = newBranch(['s1', 's2']);
const b = newBranch(['s3', 's5']);
expect(getRootSnapshot(a, b)).toBeNull();
});
it('works with same states', () => {
const a = newBranch(['s1', 's2', 's3']);
const b = newBranch(['s1', 's2', 's3']);
expect(getRootSnapshot(a, b)).toBe('s3');
});
it('works with subset', () => {
const a = newBranch(['s1']);
const b = newBranch(['s1', 's2', 's3']);
expect(getRootSnapshot(a, b)).toBe('s1');
expect(getRootSnapshot(b, a)).toBe('s1');
});
it('works with missing middle', () => {
const a = newBranch(['s1', 's3', 's4']);
const b = newBranch(['s1', 's2', 's3', 's6']);
expect(getRootSnapshot(a, b)).toBe('s3');
expect(getRootSnapshot(b, a)).toBe('s3');
});
it('works with missing middle', () => {
const a = newBranch(['s1', 's3']);
const b = newBranch(['s1', 's2', 's3']);
expect(getRootSnapshot(a, b)).toBe('s3');
expect(getRootSnapshot(b, a)).toBe('s3');
});
});
describe('updateStateWithConflictResolutions()', () => {
const A1 = { key: 'a', blob: '1932cc749f57a77ba4a231d62559c47ee931ee2c', name: '' };
const A2 = { key: 'a', blob: 'cf2ec18c2e686b4c966725f30050120d42d4b2e5', name: '' };
const B1 = { key: 'b', blob: '05ab2ec03c7e32eff27305b4a11c573a3843fa01', name: '' };
const C1 = { key: 'c', blob: 'db7d35b96291b823e674bf23ac59e6e116b1890e', name: '' };
const D1 = { key: 'd', blob: 'f23ac59e6e11db7d35b96291b823e674b6b1890e', name: '' };
const E1 = { key: 'e', blob: '1db7d35b96291f23ac59e6e1b823e674b6b1890e', name: '' };
const F1 = { key: 'f', blob: '6291b823e674b6b137172d35b9f23ac59e6e190e', name: '' };
it('does it', () => {
const state = [A1, B1, E1, F1];
const conflicts = [
{
key: A1.key,
name: A1.name,
mineBlob: A1.blob,
theirsBlob: A2.blob,
choose: A1.blob,
},
{
key: B1.key,
name: B1.name,
mineBlob: B1.blob,
theirsBlob: null,
choose: null,
},
{
key: C1.key,
name: C1.name,
mineBlob: null,
theirsBlob: C1.blob,
choose: C1.blob,
},
{
key: D1.key,
name: D1.name,
mineBlob: D1.blob,
theirsBlob: null,
choose: null,
},
];
expect(updateStateWithConflictResolutions(state, conflicts)).toEqual([A1, E1, F1, C1]);
});
});
describe('preMergeCheck()', () => {
const A1 = { key: 'a', blob: '1932cc749f57a77ba4a231d62559c47ee931ee2c', name: '' };
const A2 = { key: 'a', blob: 'cf2ec18c2e686b4c966725f30050120d42d4b2e5', name: '' };
const B1 = { key: 'b', blob: '05ab2ec03c7e32eff27305b4a11c573a3843fa01', name: '' };
const B2 = { key: 'b', blob: 'eb157a78ebd367a004c5a32a18d6d8c18e60ce00', name: '' };
const DA1 = { key: 'a', document: { id: 'a', v: 1 }, name: '' };
const DA2 = { key: 'a', document: { id: 'a', v: 2 }, name: '' };
const DB1 = { key: 'b', document: { id: 'b', v: 1 }, name: '' };
const DC1 = { key: 'c', document: { id: 'c', v: 1 }, name: '' };
it('no changes', () => {
const trunk = [A1];
const other = [A1];
const cands = [DA1];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [],
dirty: [],
});
});
it('candidates same as trunk but not other', () => {
const trunk = [A1, B1];
const other = [A2, B2];
const cands = [DA1, DB1];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [],
dirty: [],
});
});
it('candidate does not exist but trunk and other do', () => {
const trunk = [A1];
const other = [A1];
const cands = [];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [],
dirty: [],
});
});
it('candidate different than trunk and other does not exist', () => {
const trunk = [A1];
const other = [];
const cands = [DA2];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [DA2],
dirty: [],
});
});
it('candidate different than other and trunk does not exist', () => {
const trunk = [];
const other = [A1];
const cands = [DA2];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [DA2],
dirty: [],
});
});
it('candidate modified but trunk and other are same', () => {
const trunk = [A1];
const other = [A1];
const cands = [DA2];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [],
dirty: [DA2],
});
});
it('candidate not same as trunk but same as other', () => {
const trunk = [A1];
const other = [A2];
const cands = [DA2];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [],
dirty: [],
});
});
it('candidate not same as other but same as trunk', () => {
const trunk = [A1];
const other = [A2];
const cands = [DA1];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [],
dirty: [],
});
});
it('candidate not same as trunk and not in other', () => {
const trunk = [A1];
const other = [];
const cands = [DA2];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [DA2],
dirty: [],
});
});
it('candidate outside of history', () => {
const trunk = [A1];
const other = [A2];
const cands = [DC1];
expect(preMergeCheck(trunk, other, cands)).toEqual({
conflicts: [],
dirty: [DC1],
});
});
});
describe('compareBranches()', () => {
it('empty states are the same', () => {
const a = newBranch([]);
const b = newBranch([]);
expect(compareBranches(a, b)).toEqual({
ahead: 0,
behind: 0,
});
});
it('works with other empty', () => {
const a = newBranch([]);
const b = newBranch(['1']);
expect(compareBranches(a, b)).toEqual({
ahead: 0,
behind: 1,
});
});
it('works with both null', () => {
const a = null;
const b = null;
expect(compareBranches(a, b)).toEqual({
ahead: 0,
behind: 0,
});
});
it('works with null', () => {
const a = null;
const b = newBranch(['1']);
expect(compareBranches(a, b)).toEqual({
ahead: 0,
behind: 1,
});
});
it('works with other null', () => {
const a = newBranch(['1']);
const b = null;
expect(compareBranches(a, b)).toEqual({
ahead: 1,
behind: 0,
});
});
it('works with one empty', () => {
const a = newBranch(['1']);
const b = newBranch([]);
expect(compareBranches(a, b)).toEqual({
ahead: 1,
behind: 0,
});
});
it('same states are the same', () => {
const a = newBranch(['1']);
const b = newBranch(['1']);
expect(compareBranches(a, b)).toEqual({
ahead: 0,
behind: 0,
});
});
it('same final states are the same', () => {
const a = newBranch(['1', '2']);
const b = newBranch(['0', '2']);
expect(compareBranches(a, b)).toEqual({
ahead: 0,
behind: 0,
});
});
it('subset of B is ahead', () => {
const a = newBranch(['1', '2', '3']);
const b = newBranch(['0', '2']);
expect(compareBranches(a, b)).toEqual({
ahead: 1,
behind: 0,
});
});
it('subset of A is behind', () => {
const a = newBranch(['1', '2']);
const b = newBranch(['0', '2', '3']);
expect(compareBranches(a, b)).toEqual({
ahead: 0,
behind: 1,
});
});
it('states have diverged', () => {
const a = newBranch(['1', '2', '3', '5']);
const b = newBranch(['0', '2', '3', '4']);
expect(compareBranches(a, b)).toEqual({
ahead: 1,
behind: 1,
});
});
it('states have diverged scenario 2', () => {
const a = newBranch(['1', '2', '3', '5', '9']);
const b = newBranch(['0', '2', '3', '4', '8', '11']);
expect(compareBranches(a, b)).toEqual({
ahead: 2,
behind: 3,
});
});
});
describe('hashDocument()', () => {
it('ignore object key order', () => {
const result1 = hashDocument({ c: 'c', a: 'a', b: 'b' });
const result2 = hashDocument({ a: 'a', b: 'b', c: 'c' });
expect(result1.hash).toBe(result2.hash);
expect(result1.hash).toBe('fed03259e8027e1fab2e2e57b402671bef7f3eb9');
expect(result2.hash).toBe('fed03259e8027e1fab2e2e57b402671bef7f3eb9');
expect(result1.content.toString('utf8')).toBe('{"a":"a","b":"b","c":"c"}');
expect(result2.content.toString('utf8')).toBe('{"a":"a","b":"b","c":"c"}');
});
it('works on recursive things', () => {
const result1 = hashDocument({
arr: [{ b: 'b', a: 'a' }],
obj: {
obj2: { b: 'b', a: 'a' },
},
});
const result2 = hashDocument({
arr: [{ b: 'b', a: 'a' }],
obj: {
obj2: { a: 'a', b: 'b' },
},
});
expect(result1.hash).toBe(result2.hash);
expect(result1.hash).toBe('7eaaa8a03bada54b403aeada681aad6892a28ab3');
expect(result2.hash).toBe('7eaaa8a03bada54b403aeada681aad6892a28ab3');
expect(result1.content.toString('utf8')).toBe(
'{"arr":[{"a":"a","b":"b"}],"obj":{"obj2":{"a":"a","b":"b"}}}',
);
expect(result2.content.toString('utf8')).toBe(
'{"arr":[{"a":"a","b":"b"}],"obj":{"obj2":{"a":"a","b":"b"}}}',
);
});
it('array order matters', () => {
const result1 = hashDocument(['c', 'a', 'b']);
const result2 = hashDocument(['a', 'b', 'c']);
expect(result1.hash).not.toBe(result2.hash);
expect(result1.hash).toBe('edf94aad27c26bdcfe7477e0ed68991cbaedf8d8');
expect(result2.hash).toBe('e13460afb1e68af030bb9bee8344c274494661fa');
expect(result1.content.toString('utf8')).toBe('["c","a","b"]');
expect(result2.content.toString('utf8')).toBe('["a","b","c"]');
});
it('works with strange types', () => {
const sDate1 = hashDocument(new Date(1541178019555));
const sDate2 = hashDocument('2018-11-02T17:00:19.555Z');
expect(sDate1.hash).toBe(sDate2.hash);
expect(sDate1.hash).toBe('ec0a18c47fb39de00204c57b5b492fd408394a01');
expect(sDate2.hash).toBe('ec0a18c47fb39de00204c57b5b492fd408394a01');
expect(sDate1.content.toString('utf8')).toBe('"2018-11-02T17:00:19.555Z"');
expect(sDate2.content.toString('utf8')).toBe('"2018-11-02T17:00:19.555Z"');
const sNull2 = hashDocument('null');
expect(sNull2.hash).toBe('8c1030365643f1f4b7f00e3d88c0a3c555522b60');
expect(sNull2.content.toString('utf8')).toBe('"null"');
});
it('skips non-json types', () => {
const sFunc1 = hashDocument({
a: [0, () => null],
fn: () => null,
});
const sFunc2 = hashDocument({
a: [0],
});
expect(sFunc1.hash).toBe(sFunc2.hash);
expect(sFunc1.hash).toBe('38b742facb1034438d82cf1e294d9e71051bf120');
expect(sFunc2.hash).toBe('38b742facb1034438d82cf1e294d9e71051bf120');
expect(sFunc1.content.toString('utf8')).toBe('{"a":[0]}');
expect(sFunc2.content.toString('utf8')).toBe('{"a":[0]}');
});
it('fails on undefined', () => {
try {
hashDocument(undefined);
hashDocument();
} catch (err) {
return;
}
throw new Error('Expected hashDocument(undefined) to fail');
});
it('ignores object keys that do not matter', () => {
const result1 = hashDocument({ a: 'a', modified: 456 });
const result2 = hashDocument({ a: 'a', modified: 123 });
const result3 = hashDocument({ a: 'b', modified: 123 });
expect(result1.hash).toBe(result2.hash);
expect(result1.hash).not.toBe(result3.hash);
});
});
});
function newSnapshot(n, state) {
return {
id: `snapshot_${n}`,
created: new Date(),
parent: '00000',
author: 'author_1',
name: `Snapshot ${n}`,
description: `Test snapshot #${n}`,
state,
};
}
function newStateEntry(key, blob) {
return {
key,
blob,
name: `Blob ${key}.${blob}`,
};
}
function newCandidate(key, n) {
return {
key,
name: `Candidate ${n}`,
content: `Content for candidate ${key}.${n}`,
};
}
function newBranch(snapshots) {
return {
name: '',
created: new Date(0),
modified: new Date(0),
snapshots,
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,42 @@
// @flow
export function projects(): string {
return `/projects/`;
}
export function projectBase(projectId: string): string {
return `${projects()}${projectId}/`;
}
export function head(projectId: string): string {
return `${projectBase(projectId)}head.json`;
}
export function project(projectId: string): string {
return `${projectBase(projectId)}meta.json`;
}
export function blobs(projectId: string): string {
return `${projectBase(projectId)}blobs/`;
}
export function blob(projectId: string, blobId: string): string {
const subPath = `${blobId.slice(0, 2)}/${blobId.slice(2)}`;
return `${blobs(projectId)}${subPath}`;
}
export function snapshots(projectId: string): string {
return `${projectBase(projectId)}snapshots/`;
}
export function snapshot(projectId: string, snapshotId: string): string {
return `${snapshots(projectId)}${snapshotId}.json`;
}
export function branches(projectId: string): string {
return `${projectBase(projectId)}branches/`;
}
export function branch(projectId: string, branchName: string): string {
return `${branches(projectId)}${branchName}.json`;
}
+478
View File
@@ -0,0 +1,478 @@
// @flow
import clone from 'clone';
import crypto from 'crypto';
import { deterministicStringify } from '../lib/deterministicStringify';
import type {
Branch,
DocumentKey,
MergeConflict,
Snapshot,
SnapshotState,
SnapshotStateEntry,
SnapshotStateMap,
StageEntry,
StatusCandidate,
StatusCandidateMap,
} from '../types';
export function generateSnapshotStateMap(snapshot: Snapshot | null): SnapshotStateMap {
if (!snapshot) {
return {};
}
return generateStateMap(snapshot.state);
}
export function generateStateMap(state: SnapshotState | null): SnapshotStateMap {
if (!state) {
return {};
}
const map = {};
for (const entry of state) {
map[entry.key] = entry;
}
return map;
}
export function generateCandidateMap(candidates: Array<StatusCandidate>): StatusCandidateMap {
const map = {};
for (const candidate of candidates) {
map[candidate.key] = candidate;
}
return map;
}
export function combinedMapKeys<T: SnapshotStateMap | StatusCandidateMap>(
...maps: Array<T>
): Array<DocumentKey> {
const keyMap = {};
for (const map of maps) {
for (const key of Object.keys(map)) {
keyMap[key] = true;
}
}
return Object.keys(keyMap);
}
export function threeWayMerge(
root: SnapshotState,
trunk: SnapshotState,
other: SnapshotState,
): { state: SnapshotState, conflicts: Array<MergeConflict> } {
const stateRoot = generateStateMap(root);
const stateTrunk = generateStateMap(trunk);
const stateOther = generateStateMap(other);
const allKeys = combinedMapKeys(stateRoot, stateTrunk, stateOther);
const newState: SnapshotState = [];
const conflicts: Array<MergeConflict> = [];
for (const key of allKeys) {
const root = stateRoot[key] || null;
const trunk = stateTrunk[key] || null;
const other = stateOther[key] || null;
// This condition tree checks every possible case that root, trunk, other can be in
// separately. YES, it could be simplified but we want to expand every case to make
// it as bulletproof and readable as possible.
//
// Here are the possible combinations:
// root => [ exists, missing ]
// trunk => [ exists, missing, modified ]
// other => [ exists, missing, modified ]
//
// Therefore, the total number of cases is equal to 2 * 3! = 12
// ~~~~~~~~~~ //
// Unmodified //
// ~~~~~~~~~~ //
// (1/12)
// Unmodified
if (root && trunk && other && root.blob === trunk.blob && root.blob === other.blob) {
newState.push(trunk);
continue;
}
// ~~~~~~~~~ //
// Deletions //
// ~~~~~~~~~ //
// (2/12)
// Deleted in both
if (root && trunk === null && other === null) {
continue;
}
// (3/12)
// Deleted in trunk
if (root && trunk === null && other && other.blob === root.blob) {
continue;
}
// (4/12)
// Deleted in other
if (root && trunk && other === null && trunk.blob === root.blob) {
continue;
}
// ~~~~~~~~~ //
// Additions //
// ~~~~~~~~~ //
// (5/12)
// Added in both
if (root === null && trunk && other) {
if (trunk.blob !== other.blob) {
conflicts.push({
key,
name: other.name,
message: 'both added',
mineBlob: trunk.blob,
theirsBlob: other.blob,
choose: other.blob,
});
}
newState.push(trunk || other);
continue;
}
// (6/12)
// Added in trunk
if (root === null && trunk && other === null) {
newState.push(trunk);
continue;
}
// (7/12)
// Added in other
if (root === null && trunk === null && other) {
newState.push(other);
continue;
}
// ~~~~~~~~~~~~~ //
// Modifications //
// ~~~~~~~~~~~~~ //
// (8/12)
// Modified in both
if (root && trunk && other && root.blob !== trunk.blob && root.blob !== other.blob) {
if (trunk.blob !== other.blob) {
conflicts.push({
key,
name: other.name,
message: 'both modified',
mineBlob: trunk.blob,
theirsBlob: other.blob,
choose: other.blob,
});
}
newState.push(trunk);
continue;
}
// (9/12)
// Modified in trunk
if (root && trunk && other && root.blob !== trunk.blob && root.blob === other.blob) {
newState.push(trunk);
continue;
}
// (10/12)
// Modified in other
if (root && trunk && other && root.blob === trunk.blob && root.blob !== other.blob) {
newState.push(other);
continue;
}
// ~~~~~~ //
// Combos //
// ~~~~~~ //
// (11/12)
// Deleted in trunk and modified in other
if (root && trunk === null && other && other.blob !== root.blob) {
conflicts.push({
key,
name: other.name,
message: 'you deleted and they modified',
mineBlob: null,
theirsBlob: other.blob,
choose: other.blob,
});
newState.push(other);
continue;
}
// (12/12)
// Deleted in other and modified in trunk
if (root && trunk && other === null && trunk.blob !== root.blob) {
conflicts.push({
key,
name: root.name,
message: 'they deleted and you modified',
mineBlob: trunk.blob,
theirsBlob: null,
choose: trunk.blob,
});
newState.push(trunk);
continue;
}
// This should never actually happen, but let's error just to be safe
throw new Error('3-way merge hit impossible state');
}
return {
state: newState,
conflicts: conflicts,
};
}
export function compareBranches(
a: Branch | null,
b: Branch | null,
): { ahead: number, behind: number } {
const snapshotsA = a ? a.snapshots : [];
const snapshotsB = b ? b.snapshots : [];
const latestA = snapshotsA[snapshotsA.length - 1] || null;
const latestB = snapshotsB[snapshotsB.length - 1] || null;
const result = {
ahead: 0,
behind: 0,
};
if (latestA === latestB) {
return result;
}
if (latestA === null) {
result.behind = snapshotsB.length;
return result;
}
if (latestB === null) {
result.ahead = snapshotsA.length;
return result;
}
const root = getRootSnapshot(a, b);
if (root === null) {
return result;
}
const indexOfRootInA = snapshotsA.indexOf(root);
const indexOfRootInB = snapshotsB.indexOf(root);
result.ahead = snapshotsA.length - indexOfRootInA - 1;
result.behind = snapshotsB.length - indexOfRootInB - 1;
return result;
}
export function stateDelta(
base: SnapshotState,
desired: SnapshotState,
): {
add: Array<SnapshotStateEntry>,
update: Array<SnapshotStateEntry>,
remove: Array<SnapshotStateEntry>,
} {
const result = {
add: [],
update: [],
remove: [],
};
const stateMapStart = generateStateMap(base);
const stateMapFinish = generateStateMap(desired);
for (const key of combinedMapKeys(stateMapStart, stateMapFinish)) {
const start = stateMapStart[key];
const finish = stateMapFinish[key];
if (!start && finish) {
result.add.push(finish);
continue;
}
if (start && !finish) {
result.remove.push(start);
continue;
}
if (start && finish && start.blob !== finish.blob) {
result.update.push(finish);
continue;
}
}
return result;
}
export function getStagable(
state: SnapshotState,
candidates: Array<StatusCandidate>,
): Array<StageEntry> {
const stagable: Array<StageEntry> = [];
const stateMap = generateStateMap(state);
const candidateMap = generateCandidateMap(candidates);
for (const key of combinedMapKeys(stateMap, candidateMap)) {
const entry = stateMap[key];
const candidate = candidateMap[key];
if (!entry && candidate) {
const { name, document } = candidate;
const { hash: blobId, content: blobContent } = hashDocument(document);
stagable.push({ key, name, blobId, blobContent, added: true });
continue;
}
if (entry && !candidate) {
const { name, blob: blobId } = entry;
stagable.push({ key, name, blobId, deleted: true });
continue;
}
if (entry && candidate) {
const { document, name } = candidate;
const { hash: blobId, content: blobContent } = hashDocument(document);
if (entry.blob !== blobId) {
stagable.push({ key, name, blobId, blobContent, modified: true });
}
continue;
}
}
return stagable;
}
export function getRootSnapshot(a: Branch | null, b: Branch | null): string | null {
const snapshotsA = a ? a.snapshots : [];
const snapshotsB = b ? b.snapshots : [];
let rootSnapshotId = '';
for (let ai = snapshotsA.length - 1; ai >= 0; ai--) {
for (let bi = snapshotsB.length - 1; bi >= 0; bi--) {
if (snapshotsA[ai] === snapshotsB[bi]) {
return snapshotsA[ai];
}
}
}
return rootSnapshotId || null;
}
export function preMergeCheck(
trunkState: SnapshotState,
otherState: SnapshotState,
candidates: Array<StatusCandidate>,
): {
conflicts: Array<StatusCandidate>,
dirty: Array<StatusCandidate>,
} {
const result = {
conflicts: [],
dirty: [],
};
const trunkMap = generateStateMap(trunkState);
const otherMap = generateStateMap(otherState);
for (const candidate of candidates) {
const { key } = candidate;
const trunk = trunkMap[key];
const other = otherMap[key];
// Candidate is not in trunk or other (not yet in version control)
if (!trunk && !other) {
result.dirty.push(candidate);
continue;
}
const { hash: blobId } = hashDocument(candidate.document);
// Candidate is same as trunk (unchanged) so anything goes
if (trunk && trunk.blob === blobId) {
continue;
}
// Candidate is the same as trunk (nothing to do)
if (trunk && blobId === trunk.blob) {
continue;
}
// Candidate is the same as other (would update to same value)
if (other && blobId === other.blob) {
continue;
}
// Candidate is different but trunk and other are the same (preserve safe change)
if (
other &&
trunk &&
other.blob === trunk.blob &&
blobId !== other.blob &&
blobId !== trunk.blob
) {
result.dirty.push(candidate);
continue;
}
// All other cases result in conflict
result.conflicts.push(candidate);
}
return result;
}
export function hashDocument(doc: Object): { content: string, hash: string } {
if (!doc) {
throw new Error('Cannot hash undefined value');
}
// Remove fields we don't care about for sync purposes
const newDoc = clone(doc);
delete newDoc.modified;
const content = deterministicStringify(newDoc);
const hash = crypto
.createHash('sha1')
.update(content)
.digest('hex');
return { hash, content };
}
export function updateStateWithConflictResolutions(
state: SnapshotState,
conflicts: Array<MergeConflict>,
): SnapshotState {
const newStateMap = generateStateMap(state);
for (const { choose, key, name } of conflicts) {
const stateEntry = state.find(e => e.key === key);
// Not in the state, but we choose the conflict
if (!stateEntry && choose !== null) {
newStateMap[key] = { key, name, blob: choose };
continue;
}
// Add the conflict
if (choose !== null) {
newStateMap[key] = { ...stateEntry, blob: choose };
continue;
}
// Chose to delete it, so don't add to state
delete newStateMap[key];
}
return Object.keys(newStateMap).map(k => newStateMap[k]);
}
+18 -2
View File
@@ -1,7 +1,18 @@
// @flow
import type { PluginArgumentEnumOption } from './extensions';
export type NunjucksParsedTagArg = {
type: 'string' | 'number' | 'boolean' | 'number' | 'variable' | 'expression',
type:
| 'string'
| 'number'
| 'boolean'
| 'number'
| 'variable'
| 'expression'
| 'enum'
| 'file'
| 'model',
value: string | number | boolean,
defaultValue?: string | number | boolean,
forceVariable?: boolean,
@@ -11,6 +22,10 @@ export type NunjucksParsedTagArg = {
quotedBy?: '"' | "'",
validate?: (value: any) => string,
hide?: (Array<NunjucksParsedTagArg>) => boolean,
model?: string,
options?: Array<PluginArgumentEnumOption>,
itemTypes?: Array<string>,
extensions?: Array<string>,
};
export type NunjucksParsedTag = {
@@ -172,7 +187,8 @@ export function getDefaultFill(name: string, args: Array<NunjucksParsedTagArg>):
switch (argDefinition.type) {
case 'enum':
const { defaultValue, options } = argDefinition;
const value = defaultValue !== undefined ? defaultValue : options[0].value;
const fallback = options && options.length ? options[0].value : '';
const value = defaultValue !== undefined ? String(defaultValue) : String(fallback);
return `'${value}'`;
case 'number':
return `${parseFloat(argDefinition.defaultValue) || 0}`;
@@ -147,7 +147,7 @@ class Dropdown extends PureComponent {
this._dropdownList.style.minWidth = 'initial';
this._dropdownList.style.maxWidth = 'initial';
const screenMargin = 5;
const screenMargin = 6;
const { right, wide } = this.props;
if (right || wide) {
@@ -350,7 +350,11 @@ class Dropdown extends PureComponent {
finalChildren = [
dropdownButtons[0],
ReactDOM.createPortal(
<div key="item" className={menuClasses} ref={this._addDropdownMenuRef}>
<div
key="item"
className={menuClasses}
ref={this._addDropdownMenuRef}
aria-hidden={!open}>
<div className="dropdown__backdrop theme--transparent-overlay" />
<div
key={uniquenessKey}
@@ -0,0 +1,41 @@
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
type Props = {
indeterminate: boolean,
};
@autobind
class IndeterminateCheckbox extends React.PureComponent<Props> {
input: ?HTMLInputElement;
_setRef(n: ?HTMLInputElement) {
this.input = n;
}
_update() {
if (this.input) {
this.input.indeterminate = this.props.indeterminate;
}
}
componentDidMount() {
this._update();
}
componentDidUpdate() {
this._update();
}
render() {
const {
indeterminate, // eslint-disable-line no-unused-vars
...otherProps
} = this.props;
return <input ref={this._setRef} type="checkbox" {...otherProps} />;
}
}
export default IndeterminateCheckbox;
@@ -77,7 +77,7 @@ class Modal extends PureComponent {
this._node = n;
}
show() {
show(options) {
const { freshState } = this.props;
const { forceRefreshCounter } = this.state;
@@ -91,6 +91,9 @@ class Modal extends PureComponent {
return;
}
// Allow instance-based onHide method
this.onHide = options ? options.onHide : null;
setTimeout(() => this._node && this._node.focus());
}
@@ -109,6 +112,7 @@ class Modal extends PureComponent {
hide() {
this.setState({ open: false });
this.props.onHide && this.props.onHide();
this.onHide && this.onHide();
}
render() {
@@ -140,6 +144,7 @@ class Modal extends PureComponent {
tabIndex="-1"
className={classes}
style={styles}
aria-hidden={!open}
onClick={this._handleClick}>
<div className="modal__backdrop overlay theme--transparent-overlay" data-close-modal />
<div className="modal__content__wrapper">
@@ -81,7 +81,10 @@ class PromptButton extends PureComponent {
const { state } = this.state;
const finalConfirmMessage = (confirmMessage || 'Click to confirm').trim();
const finalConfirmMessage = (typeof confirmMessage === 'string'
? confirmMessage
: 'Click to confirm'
).trim();
const finalDoneMessage = doneMessage || 'Done';
let innerMsg;
@@ -10,7 +10,8 @@ import { showModal } from '../modals/index';
import FilterHelpModal from '../modals/filter-help-modal';
import * as misc from '../../../common/misc';
import prettify from 'insomnia-prettify';
import { DEBOUNCE_MILLIS, isMac } from '../../../common/constants';
import { DEBOUNCE_MILLIS, EDITOR_KEY_MAP_VIM, isMac } from '../../../common/constants';
import { keyboardKeys as keyCodes } from '../../../common/keyboard-keys';
import './base-imports';
import { getTagDefinitions } from '../../../templating/index';
import Dropdown from '../base/dropdown/dropdown';
@@ -235,6 +236,7 @@ class CodeEditor extends React.Component {
return;
}
window.codeMirror = this.codeMirror;
editorStates[uniquenessKey] = {
scroll: this.codeMirror.getScrollInfo(),
selections: this.codeMirror.listSelections(),
@@ -287,6 +289,7 @@ class CodeEditor extends React.Component {
this.codeMirror.on('blur', this._codemirrorBlur);
this.codeMirror.on('paste', this._codemirrorPaste);
this.codeMirror.on('scroll', this._codemirrorScroll);
this.codeMirror.on('keyHandled', this._codemirrorKeyHandled);
// Prevent these things if we're type === "password"
this.codeMirror.on('copy', this._codemirrorPreventWhenTypePassword);
@@ -664,6 +667,18 @@ class CodeEditor extends React.Component {
this._persistState();
}
_codemirrorKeyHandled(codeMirror, keyName, event) {
const { keyMap } = this.props;
const { keyCode } = event;
const isVimKeyMap = keyMap === EDITOR_KEY_MAP_VIM;
const pressedEscape = keyCode === keyCodes.esc.keyCode;
if (isVimKeyMap && pressedEscape) {
event.stopPropagation();
}
}
_codemirrorValueBeforeChange(doc, change) {
// If we're in single-line mode, merge all changed lines into one
if (this.props.singleLine && change.text && change.text.length > 1) {
@@ -88,7 +88,7 @@ class CookieList extends React.PureComponent<Props> {
<PromptButton
className="btn btn--super-compact btn--outlined"
addIcon
confirmMessage=" "
confirmMessage=""
onClick={e => this._handleDeleteCookie(cookie)}
title="Delete cookie">
<i className="fa fa-trash-o" />
@@ -69,12 +69,12 @@ class AuthDropdown extends React.PureComponent<Props> {
}
renderAuthType(type: string, nameOverride: string | null = null) {
const {authentication} = this.props.request;
const { authentication } = this.props.request;
const currentType = authentication.type || AUTH_NONE;
return (
<DropdownItem onClick={this._handleTypeChange} value={type}>
{currentType === type ? <i className="fa fa-check"/> : <i className="fa fa-empty"/>}{' '}
{currentType === type ? <i className="fa fa-check" /> : <i className="fa fa-empty" />}{' '}
{nameOverride || getAuthTypeName(type, true)}
</DropdownItem>
);
@@ -93,15 +93,14 @@ class EnvironmentsDropdown extends React.PureComponent<Props> {
<Dropdown ref={this._setDropdownRef} {...other} className={className}>
<DropdownButton className="btn btn--super-compact no-wrap">
<div className="sidebar__menu__thing">
{!activeEnvironment &&
subEnvironments.length > 0 && (
<Tooltip
message="No environments active. Please select one to use."
className="space-right"
position="right">
<i className="fa fa-exclamation-triangle notice" />
</Tooltip>
)}
{!activeEnvironment && subEnvironments.length > 0 && (
<Tooltip
message="No environments active. Please select one to use."
className="space-right"
position="right">
<i className="fa fa-exclamation-triangle notice" />
</Tooltip>
)}
<div className="sidebar__menu__thing__text">
{activeEnvironment &&
activeEnvironment.color &&
@@ -40,7 +40,7 @@ class ResponseHistoryDropdown extends PureComponent {
}
renderDropdownItem(response, i) {
const { activeResponse } = this.props;
const { activeResponse, requestMethod } = this.props;
const activeResponseId = activeResponse ? activeResponse._id : 'n/a';
const active = response._id === activeResponseId;
const message =
@@ -59,7 +59,7 @@ class ResponseHistoryDropdown extends PureComponent {
statusCode={response.statusCode}
statusMessage={response.statusMessage || null}
/>
<URLTag small url={response.url} />
<URLTag small url={response.url} method={requestMethod} />
<TimeTag milliseconds={response.elapsedTime} small />
<SizeTag bytesRead={response.bytesRead} bytesContent={response.bytesContent} small />
{!response.requestVersionId && <i className="icon fa fa-info-circle" title={message} />}
@@ -116,6 +116,7 @@ ResponseHistoryDropdown.propTypes = {
handleDeleteResponse: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
requestId: PropTypes.string.isRequired,
requestMethod: PropTypes.string.isRequired,
responses: PropTypes.arrayOf(PropTypes.object).isRequired,
// Optional
@@ -1,218 +1,464 @@
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import classnames from 'classnames';
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../base/dropdown';
import { showModal } from '../modals';
import * as syncStorage from '../../../sync/storage';
import * as session from '../../../sync/session';
import * as sync from '../../../sync';
import WorkspaceShareSettingsModal from '../modals/workspace-share-settings-modal';
import SetupSyncModal from '../modals/setup-sync-modal';
import type { Workspace } from '../../../models/workspace';
import { showAlert, showModal } from '../modals';
import SyncStagingModal from '../modals/sync-staging-modal';
import { batchModifyDocs } from '../../../common/database';
import HelpTooltip from '../help-tooltip';
import Link from '../base/link';
import SyncHistoryModal from '../modals/sync-history-modal';
import SyncShareModal from '../modals/sync-share-modal';
import SyncBranchesModal from '../modals/sync-branches-modal';
import VCS from '../../../sync/vcs';
import type { Snapshot, Status, StatusCandidate } from '../../../sync/types';
import ErrorModal from '../modals/error-modal';
import Tooltip from '../tooltip';
import LoginModal from '../modals/login-modal';
import * as session from '../../../account/session';
import PromptButton from '../base/prompt-button';
// Stop refreshing if user hasn't been active in this long
const REFRESH_USER_ACTIVITY = 1000 * 60 * 10;
// Refresh dropdown periodically
const REFRESH_PERIOD = 1000 * 60 * 1;
type Props = {
workspace: Workspace,
vcs: VCS,
syncItems: Array<StatusCandidate>,
// Optional
className?: string,
};
type State = {
loggedIn: boolean | null,
loading: boolean,
resourceGroupId: string | null,
syncMode: string | null,
syncPercent: number,
workspaceName: string,
currentBranch: string,
localBranches: Array<string>,
compare: {
ahead: number,
behind: number,
},
status: Status,
initializing: boolean,
historyCount: number,
loadingPull: boolean,
loadingPush: boolean,
};
@autobind
class SyncDropdown extends React.PureComponent<Props, State> {
_hasPrompted: boolean;
_isMounted: boolean;
checkInterval: IntervalID;
refreshOnNextSyncItems = false;
lastUserActivity = Date.now();
constructor(props: Props) {
super(props);
this._hasPrompted = false;
this._isMounted = false;
this.state = {
loggedIn: null,
loading: false,
resourceGroupId: null,
syncMode: null,
syncPercent: 0,
workspaceName: '',
localBranches: [],
currentBranch: '',
compare: {
ahead: 0,
behind: 0,
},
historyCount: 0,
initializing: true,
loadingPull: false,
loadingPush: false,
status: {
key: 'n/a',
stage: {},
unstaged: {},
},
};
}
_handleShowShareSettings() {
showModal(WorkspaceShareSettingsModal, { workspace: this.props.workspace });
}
async refreshMainAttributes(extraState?: Object = {}) {
const { vcs, syncItems } = this.props;
const localBranches = (await vcs.getBranches()).sort();
const currentBranch = await vcs.getBranch();
const historyCount = await vcs.getHistoryCount();
const status = await vcs.status(syncItems, {});
async _handleSyncResourceGroupId() {
const { resourceGroupId } = this.state;
const newState = {
status,
historyCount,
localBranches,
currentBranch,
...extraState,
};
// Set loading state
this.setState({ loading: true });
await sync.getOrCreateConfig(resourceGroupId);
await sync.pull(resourceGroupId);
await sync.push(resourceGroupId);
await this._reloadData();
// Unset loading state
this.setState({ loading: false });
}
async _reloadData() {
const loggedIn = session.isLoggedIn();
if (loggedIn !== this.state.loggedIn) {
this.setState({ loggedIn });
// Do the remote stuff
if (session.isLoggedIn()) {
try {
newState.compare = await vcs.compareRemoteBranch();
} catch (err) {
console.log('Failed to compare remote branches', err.message);
}
}
if (!loggedIn) {
return;
}
this.setState(newState);
}
// Get or create any related sync data
const { workspace } = this.props;
const { resourceGroupId } = await sync.getOrCreateResourceForDoc(workspace);
const config = await sync.getOrCreateConfig(resourceGroupId);
componentDidMount() {
this.setState({ initializing: true });
this.refreshMainAttributes()
.catch(err => console.log('[sync_menu] Error refreshing sync state', err))
.finally(() => this.setState({ initializing: false }));
// Analyze it
const dirty = await syncStorage.findDirtyResourcesForResourceGroup(resourceGroupId);
const all = await syncStorage.findResourcesForResourceGroup(resourceGroupId);
const numClean = all.length - dirty.length;
const syncPercent = all.length === 0 ? 100 : parseInt((numClean / all.length) * 1000) / 10;
// Refresh but only if the user has been active in the last n minutes
this.checkInterval = setInterval(async () => {
if (Date.now() - this.lastUserActivity < REFRESH_USER_ACTIVITY) {
await this.refreshMainAttributes();
}
}, REFRESH_PERIOD);
if (this._isMounted) {
this.setState({
resourceGroupId,
syncPercent,
syncMode: config.syncMode,
workspaceName: workspace.name,
document.addEventListener('mousemove', this._handleUserActivity);
}
componentWillUnmount() {
clearInterval(this.checkInterval);
document.removeEventListener('mousemove', this._handleUserActivity);
}
componentDidUpdate(prevProps: Props) {
const { vcs, syncItems } = this.props;
// Update if new sync items
if (syncItems !== prevProps.syncItems) {
vcs.status(syncItems, {}).then(status => {
this.setState({ status });
});
if (this.refreshOnNextSyncItems) {
this.refreshMainAttributes();
this.refreshOnNextSyncItems = false;
}
}
}
async _handleShowSyncModePrompt() {
showModal(SetupSyncModal, {
onSelectSyncMode: async syncMode => {
await this._reloadData();
_handleUserActivity() {
this.lastUserActivity = Date.now();
}
_handleShowBranchesModal() {
showModal(SyncBranchesModal, {
onHide: this.refreshMainAttributes,
});
}
_handleShowStagingModal() {
showModal(SyncStagingModal, {
onSnapshot: async () => {
await this.refreshMainAttributes();
},
handlePush: async () => {
await this._handlePushChanges();
},
});
}
componentDidMount() {
this._isMounted = true;
syncStorage.onChange(this._reloadData);
this._reloadData();
static _handleShowSharingModal() {
showModal(SyncShareModal);
}
componentWillUnmount() {
syncStorage.offChange(this._reloadData);
this._isMounted = false;
static _handleShowLoginModal() {
showModal(LoginModal);
}
componentDidUpdate() {
const { resourceGroupId, syncMode } = this.state;
async _handlePushChanges() {
const { vcs } = this.props;
this.setState({ loadingPush: true });
if (!resourceGroupId) {
return;
try {
await vcs.push();
} catch (err) {
showModal(ErrorModal, {
title: 'Push Error',
error: err,
message: 'Failed to push snapshots to remote',
});
}
// Sync has not yet been configured for this workspace, so prompt the user to do so
const isModeUnset = !syncMode || syncMode === syncStorage.SYNC_MODE_UNSET;
if (isModeUnset && !this._hasPrompted) {
this._hasPrompted = true;
this._handleShowSyncModePrompt();
await this.refreshMainAttributes({ loadingPush: false });
}
async _handlePullChanges() {
const { vcs, syncItems } = this.props;
this.setState({ loadingPull: true });
try {
const delta = await vcs.pull(syncItems);
await batchModifyDocs(delta);
this.refreshOnNextSyncItems = true;
} catch (err) {
showModal(ErrorModal, {
title: 'Pull Error',
error: err,
message: 'Failed to pull snapshots from remote',
});
}
this.setState({ loadingPull: false });
}
async _handleRollback(snapshot: Snapshot) {
const { vcs, syncItems } = this.props;
const delta = await vcs.rollback(snapshot.id, syncItems);
await batchModifyDocs(delta);
this.refreshOnNextSyncItems = true;
}
async _handleRevert() {
const { vcs, syncItems } = this.props;
try {
const delta = await vcs.rollbackToLatest(syncItems);
await batchModifyDocs(delta);
} catch (err) {
showModal(ErrorModal, {
title: 'Revert Error',
error: err,
message: 'Failed to revert changes',
});
}
}
_getSyncDescription(syncMode: string | null, syncPercentage: number) {
let el = null;
if (syncMode === syncStorage.SYNC_MODE_NEVER) {
el = <span>Sync Disabled</span>;
} else if (syncPercentage === 100) {
el = <span>Sync Up To Date</span>;
} else if (syncMode === syncStorage.SYNC_MODE_OFF) {
el = (
<span>
<i className="fa fa-pause-circle-o" /> Sync Required
</span>
);
} else if (syncMode === syncStorage.SYNC_MODE_ON) {
el = <span>Sync Pending</span>;
} else if (!syncMode || syncMode === syncStorage.SYNC_MODE_UNSET) {
el = (
<span>
<i className="fa fa-exclamation-circle" /> Configure Sync
</span>
_handleShowHistoryModal() {
showModal(SyncHistoryModal, {
handleRollback: this._handleRollback,
});
}
async _handleOpen() {
await this.refreshMainAttributes();
}
async _handleSwitchBranch(branch: string) {
const { vcs, syncItems } = this.props;
try {
const delta = await vcs.checkout(syncItems, branch);
await batchModifyDocs(delta);
} catch (err) {
showAlert({
title: 'Branch Switch Error',
message: err.message,
});
}
// We can't refresh now because we won't yet have the new syncItems
this.refreshOnNextSyncItems = true;
// Still need to do this in case sync items don't change
this.setState({ currentBranch: branch });
}
renderBranch(branch: string) {
const { currentBranch } = this.state;
const icon =
branch === currentBranch ? <i className="fa fa-tag" /> : <i className="fa fa-empty" />;
const isCurrentBranch = branch === currentBranch;
return (
<DropdownItem
key={branch}
onClick={isCurrentBranch ? null : () => this._handleSwitchBranch(branch)}
className={classnames({ bold: isCurrentBranch })}
title={isCurrentBranch ? null : `Switch to "${branch}"`}>
{icon}
{branch}
</DropdownItem>
);
}
renderButton() {
const {
currentBranch,
compare: { ahead, behind },
initializing,
status,
loadingPull,
loadingPush,
} = this.state;
const canPush = ahead > 0;
const canPull = behind > 0;
const canCreateSnapshot =
Object.keys(status.stage).length > 0 || Object.keys(status.unstaged).length > 0;
const loadIcon = <i className="fa fa-spin fa-refresh fa--fixed-width" />;
const pullToolTipMsg = canPull
? `There ${behind === 1 ? 'is' : 'are'} ${behind} snapshot${behind === 1 ? '' : 's'} to pull`
: 'No changes to pull';
const pushToolTipMsg = canPush
? `There ${ahead === 1 ? 'is' : 'are'} ${ahead} snapshot${ahead === 1 ? '' : 's'} to push`
: 'No changes to push';
const snapshotToolTipMsg = canCreateSnapshot ? 'Local changes made' : 'No local changes made';
if (currentBranch !== null) {
return (
<DropdownButton className="btn btn--compact wide text-left overflow-hidden row-spaced">
<div className="ellipsis">
<i className="fa fa-code-fork space-right" />{' '}
{initializing ? 'Initializing...' : currentBranch}
</div>
<div className="space-left">
<Tooltip message={snapshotToolTipMsg} delay={800}>
<i
className={classnames('icon fa fa-cube fa--fixed-width', {
'super-duper-faint': !canCreateSnapshot,
})}
/>
</Tooltip>
{/* Only show cloud icons if logged in */}
{session.isLoggedIn() && (
<React.Fragment>
{loadingPull ? (
loadIcon
) : (
<Tooltip message={pullToolTipMsg} delay={800}>
<i
className={classnames('fa fa-cloud-download fa--fixed-width', {
'super-duper-faint': !canPull,
})}
/>
</Tooltip>
)}
{loadingPush ? (
loadIcon
) : (
<Tooltip message={pushToolTipMsg} delay={800}>
<i
className={classnames('fa fa-cloud-upload fa--fixed-width', {
'super-duper-faint': !canPush,
})}
/>
</Tooltip>
)}
</React.Fragment>
)}
</div>
</DropdownButton>
);
} else {
return <React.Fragment>Sync</React.Fragment>;
}
return el;
}
render() {
const { className } = this.props;
const { resourceGroupId, loading, loggedIn } = this.state;
// Don't show the sync menu unless we're logged in
if (!loggedIn) {
if (!session.isLoggedIn()) {
return null;
}
if (!resourceGroupId) {
return (
<div className={className}>
<button className="btn btn--compact wide" disabled>
Initializing Sync...
</button>
</div>
);
} else {
const { syncMode, syncPercent } = this.state;
return (
<div className={className}>
<Dropdown wide className="wide tall">
<DropdownButton className="btn btn--compact wide">
{this._getSyncDescription(syncMode, syncPercent)}
</DropdownButton>
<DropdownDivider>Workspace Synced {syncPercent}%</DropdownDivider>
const { className } = this.props;
const {
localBranches,
currentBranch,
status,
historyCount,
loadingPull,
loadingPush,
compare: { ahead, behind },
} = this.state;
<DropdownItem onClick={this._handleShowSyncModePrompt}>
<i className="fa fa-wrench" />
Change Sync Mode
const canCreateSnapshot =
Object.keys(status.stage).length > 0 || Object.keys(status.unstaged).length > 0;
const visibleBranches = localBranches.filter(b => !b.match(/\.hidden$/));
return (
<div className={className}>
<Dropdown className="wide tall" onOpen={this._handleOpen}>
{this.renderButton()}
<DropdownDivider>
Insomnia Sync{' '}
<HelpTooltip>
Sync and collaborate on workspaces{' '}
<Link href="https://support.insomnia.rest/article/67-version-control">
<span className="no-wrap">
<br />
Documentation <i className="fa fa-external-link" />
</span>
</Link>
</HelpTooltip>
</DropdownDivider>
{!session.isLoggedIn() && (
<DropdownItem onClick={SyncDropdown._handleShowLoginModal}>
<i className="fa fa-sign-in" /> Log In
</DropdownItem>
)}
{/* SYNCED */}
<DropdownItem onClick={SyncDropdown._handleShowSharingModal}>
<i className="fa fa-users" />
Share Settings
</DropdownItem>
{syncMode !== syncStorage.SYNC_MODE_NEVER ? (
<DropdownItem onClick={this._handleSyncResourceGroupId} stayOpenAfterClick>
{loading ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-upload" />
)}
Sync Now
</DropdownItem>
) : null}
<DropdownItem onClick={this._handleShowBranchesModal}>
<i className="fa fa-code-fork" />
Branches
</DropdownItem>
{syncMode !== syncStorage.SYNC_MODE_NEVER ? (
<DropdownItem onClick={this._handleShowShareSettings}>
<i className="fa fa-users" />
Share Settings
</DropdownItem>
) : null}
</Dropdown>
</div>
);
}
<DropdownDivider>Local Branches</DropdownDivider>
{visibleBranches.map(this.renderBranch)}
<DropdownDivider>{currentBranch}</DropdownDivider>
<DropdownItem onClick={this._handleShowHistoryModal} disabled={historyCount === 0}>
<i className="fa fa-clock-o" />
History
</DropdownItem>
<DropdownItem
onClick={this._handleRevert}
buttonClass={PromptButton}
stayOpenAfterClick
disabled={!canCreateSnapshot || historyCount === 0}>
<i className="fa fa-undo" />
Revert Changes
</DropdownItem>
<DropdownItem onClick={this._handleShowStagingModal} disabled={!canCreateSnapshot}>
<i className="fa fa-cube" />
Create Snapshot
</DropdownItem>
<DropdownItem onClick={this._handlePullChanges} disabled={behind === 0 || loadingPull}>
{loadingPull ? (
<React.Fragment>
<i className="fa fa-spin fa-refresh" /> Pulling Snapshots...
</React.Fragment>
) : (
<React.Fragment>
<i className="fa fa-cloud-upload" /> Pull {behind || ''} Snapshot
{behind === 1 ? '' : 's'}
</React.Fragment>
)}
</DropdownItem>
<DropdownItem onClick={this._handlePushChanges} disabled={ahead === 0 || loadingPush}>
{loadingPush ? (
<React.Fragment>
<i className="fa fa-spin fa-refresh" /> Pushing Snapshots...
</React.Fragment>
) : (
<React.Fragment>
<i className="fa fa-cloud-upload" /> Push {ahead || ''} Snapshot
{ahead === 1 ? '' : 's'}
</React.Fragment>
)}
</DropdownItem>
</Dropdown>
</div>
);
}
}
@@ -0,0 +1,219 @@
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../base/dropdown';
import { showModal } from '../modals';
import * as syncStorage from '../../../sync-legacy/storage';
import * as sync from '../../../sync-legacy';
import WorkspaceShareSettingsModal from '../modals/workspace-share-settings-modal';
import SetupSyncModal from '../modals/setup-sync-modal';
import type { Workspace } from '../../../models/workspace';
import * as session from '../../../account/session';
type Props = {
workspace: Workspace,
// Optional
className?: string,
};
type State = {
loggedIn: boolean | null,
loading: boolean,
resourceGroupId: string | null,
syncMode: string | null,
syncPercent: number,
workspaceName: string,
};
@autobind
class SyncLegacyDropdown extends React.PureComponent<Props, State> {
_hasPrompted: boolean;
_isMounted: boolean;
constructor(props: Props) {
super(props);
this._hasPrompted = false;
this._isMounted = false;
this.state = {
loggedIn: null,
loading: false,
resourceGroupId: null,
syncMode: null,
syncPercent: 0,
workspaceName: '',
};
}
_handleShowShareSettings() {
showModal(WorkspaceShareSettingsModal, { workspace: this.props.workspace });
}
async _handleSyncResourceGroupId() {
const { resourceGroupId } = this.state;
// Set loading state
this.setState({ loading: true });
await sync.getOrCreateConfig(resourceGroupId);
await sync.pull(resourceGroupId);
await sync.push(resourceGroupId);
await this._reloadData();
// Unset loading state
this.setState({ loading: false });
}
async _reloadData() {
const loggedIn = session.isLoggedIn();
if (loggedIn !== this.state.loggedIn) {
this.setState({ loggedIn });
}
if (!loggedIn) {
return;
}
// Get or create any related sync data
const { workspace } = this.props;
const { resourceGroupId } = await sync.getOrCreateResourceForDoc(workspace);
const config = await sync.getOrCreateConfig(resourceGroupId);
// Analyze it
const dirty = await syncStorage.findDirtyResourcesForResourceGroup(resourceGroupId);
const all = await syncStorage.findResourcesForResourceGroup(resourceGroupId);
const numClean = all.length - dirty.length;
const syncPercent = all.length === 0 ? 100 : parseInt((numClean / all.length) * 1000) / 10;
if (this._isMounted) {
this.setState({
resourceGroupId,
syncPercent,
syncMode: config.syncMode,
workspaceName: workspace.name,
});
}
}
async _handleShowSyncModePrompt() {
showModal(SetupSyncModal, {
onSelectSyncMode: async syncMode => {
await this._reloadData();
},
});
}
componentDidMount() {
this._isMounted = true;
syncStorage.onChange(this._reloadData);
this._reloadData();
}
componentWillUnmount() {
syncStorage.offChange(this._reloadData);
this._isMounted = false;
}
componentDidUpdate() {
const { resourceGroupId, syncMode } = this.state;
if (!resourceGroupId) {
return;
}
// Sync has not yet been configured for this workspace, so prompt the user to do so
const isModeUnset = !syncMode || syncMode === syncStorage.SYNC_MODE_UNSET;
if (isModeUnset && !this._hasPrompted) {
this._hasPrompted = true;
this._handleShowSyncModePrompt();
}
}
_getSyncDescription(syncMode: string | null, syncPercentage: number) {
let el = null;
if (syncMode === syncStorage.SYNC_MODE_NEVER) {
el = <span>Sync Disabled</span>;
} else if (syncPercentage === 100) {
el = <span>Sync Up To Date</span>;
} else if (syncMode === syncStorage.SYNC_MODE_OFF) {
el = (
<span>
<i className="fa fa-pause-circle-o" /> Sync Required
</span>
);
} else if (syncMode === syncStorage.SYNC_MODE_ON) {
el = <span>Sync Pending</span>;
} else if (!syncMode || syncMode === syncStorage.SYNC_MODE_UNSET) {
el = (
<span>
<i className="fa fa-exclamation-circle" /> Configure Sync
</span>
);
}
return el;
}
render() {
const { className } = this.props;
const { resourceGroupId, loading, loggedIn } = this.state;
// Don't show the sync menu unless we're logged in
if (!loggedIn) {
return null;
}
if (!resourceGroupId) {
return (
<div className={className}>
<button className="btn btn--compact wide" disabled>
Initializing Sync...
</button>
</div>
);
} else {
const { syncMode, syncPercent } = this.state;
return (
<div className={className}>
<Dropdown wide className="wide tall">
<DropdownButton className="btn btn--compact wide">
{this._getSyncDescription(syncMode, syncPercent)}
</DropdownButton>
<DropdownDivider>Workspace Synced {syncPercent}%</DropdownDivider>
<DropdownItem onClick={this._handleShowSyncModePrompt}>
<i className="fa fa-wrench" />
Change Sync Mode
</DropdownItem>
{/* SYNCED */}
{syncMode !== syncStorage.SYNC_MODE_NEVER ? (
<DropdownItem onClick={this._handleSyncResourceGroupId} stayOpenAfterClick>
{loading ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-upload" />
)}
Sync Now
</DropdownItem>
) : null}
{syncMode !== syncStorage.SYNC_MODE_NEVER ? (
<DropdownItem onClick={this._handleShowShareSettings}>
<i className="fa fa-users" />
Share Settings
</DropdownItem>
) : null}
</Dropdown>
</div>
);
}
}
}
export default SyncLegacyDropdown;
@@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import * as classnames from 'classnames';
import classnames from 'classnames';
import Dropdown from '../base/dropdown/dropdown';
import DropdownDivider from '../base/dropdown/dropdown-divider';
import DropdownButton from '../base/dropdown/dropdown-button';
@@ -10,32 +10,59 @@ import DropdownHint from '../base/dropdown/dropdown-hint';
import SettingsModal, { TAB_INDEX_EXPORT } from '../modals/settings-modal';
import * as models from '../../../models';
import { getAppVersion } from '../../../common/constants';
import { showModal, showPrompt } from '../modals/index';
import { showAlert, showModal, showPrompt } from '../modals';
import Link from '../base/link';
import WorkspaceSettingsModal from '../modals/workspace-settings-modal';
import WorkspaceShareSettingsModal from '../modals/workspace-share-settings-modal';
import * as session from '../../../sync/session';
import LoginModal from '../modals/login-modal';
import Tooltip from '../tooltip';
import KeydownBinder from '../keydown-binder';
import { hotKeyRefs } from '../../../common/hotkeys';
import { executeHotKey } from '../../../common/hotkeys-listener';
import type { Workspace } from '../../../models/workspace';
import type { HotKeyRegistry } from '../../../common/hotkeys';
import SyncShareModal from '../modals/sync-share-modal';
import * as db from '../../../common/database';
import VCS from '../../../sync/vcs';
import HelpTooltip from '../help-tooltip';
import type { Project } from '../../../sync/types';
import * as sync from '../../../sync-legacy/index';
import PromptButton from '../base/prompt-button';
import * as session from '../../../account/session';
type Props = {
isLoading: boolean,
handleSetActiveWorkspace: (id: string) => void,
workspaces: Array<Workspace>,
unseenWorkspaces: Array<Workspace>,
activeWorkspace: Workspace,
hotKeyRegistry: HotKeyRegistry,
enableSyncBeta: boolean,
vcs: VCS | null,
// Optional
className?: string,
};
type State = {
remoteProjects: Array<Project>,
localProjects: Array<Project>,
pullingProjects: { [string]: boolean },
};
@autobind
class WorkspaceDropdown extends PureComponent {
constructor(props) {
class WorkspaceDropdown extends React.PureComponent<Props, State> {
_dropdown: ?Dropdown;
constructor(props: Props) {
super(props);
this.state = {
loggedIn: false,
remoteProjects: [],
localProjects: [],
pullingProjects: {},
};
}
async _handleDropdownOpen() {
if (this.state.loggedIn !== session.isLoggedIn()) {
this.setState({ loggedIn: session.isLoggedIn() });
}
}
async _handleDropdownHide() {
// Mark all unseen workspace as seen
for (const workspace of this.props.unseenWorkspaces) {
@@ -46,36 +73,95 @@ class WorkspaceDropdown extends PureComponent {
}
}
_setDropdownRef(n) {
async _handleDropdownOpen() {
this._refreshRemoteWorkspaces();
}
async _refreshRemoteWorkspaces() {
const { vcs } = this.props;
if (!vcs) {
return;
}
if (!session.isLoggedIn()) {
return;
}
const remoteProjects = await vcs.remoteProjects();
const localProjects = await vcs.localProjects();
this.setState({ remoteProjects, localProjects });
}
async _handlePullRemoteWorkspace(project: Project) {
const { vcs } = this.props;
if (!vcs) {
throw new Error('VCS is not defined');
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: true },
}));
try {
// Clone old VCS so we don't mess anything up while working on other projects
const newVCS = vcs.newInstance();
// Remove all projects for workspace first
await newVCS.removeProjectsForRoot(project.rootDocumentId);
// Set project, checkout master, and pull
await newVCS.setProject(project);
await newVCS.checkout([], 'master');
await newVCS.pull([]); // There won't be any existing docs since it's a new pull
const flushId = await db.bufferChanges();
for (const doc of await newVCS.allDocuments()) {
await db.upsert(doc);
}
await db.flushChanges(flushId);
// handleSetActiveWorkspace(project.rootDocumentId);
vcs.setProject(project);
await this._refreshRemoteWorkspaces();
} catch (err) {
this._dropdown && this._dropdown.hide();
showAlert({
title: 'Pull Error',
message: `Failed to pull workspace. ${err.message}`,
});
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: false },
}));
}
_setDropdownRef(n: ?Dropdown) {
this._dropdown = n;
}
_handleShowLogin() {
showModal(LoginModal);
static async _handleLogout() {
await sync.logout();
}
_handleShowExport() {
static _handleShowExport() {
showModal(SettingsModal, TAB_INDEX_EXPORT);
}
_handleShowSettings() {
static _handleShowSettings() {
showModal(SettingsModal);
}
_handleShowWorkspaceSettings() {
showModal(WorkspaceSettingsModal, {
workspace: this.props.activeWorkspace,
});
static _handleShowWorkspaceSettings() {
showModal(WorkspaceSettingsModal);
}
_handleShowShareSettings() {
showModal(WorkspaceShareSettingsModal, {
workspace: this.props.activeWorkspace,
});
}
_handleSwitchWorkspace(workspaceId) {
this.props.handleSetActiveWorkspace(workspaceId);
if (this.props.enableSyncBeta) {
showModal(SyncShareModal);
} else {
showModal(WorkspaceShareSettingsModal);
}
}
_handleWorkspaceCreate() {
@@ -91,12 +177,23 @@ class WorkspaceDropdown extends PureComponent {
});
}
_handleKeydown(e) {
_handleKeydown(e: KeyboardEvent) {
executeHotKey(e, hotKeyRefs.TOGGLE_MAIN_MENU, () => {
this._dropdown && this._dropdown.toggle(true);
});
}
componentDidUpdate(prevProps: Props) {
// Reload workspaces if we just got a new VCS instance
if (this.props.vcs && !prevProps.vcs) {
this._refreshRemoteWorkspaces();
}
}
componentDidMount() {
this._refreshRemoteWorkspaces();
}
render() {
const {
className,
@@ -105,9 +202,23 @@ class WorkspaceDropdown extends PureComponent {
unseenWorkspaces,
isLoading,
hotKeyRegistry,
handleSetActiveWorkspace,
enableSyncBeta,
...other
} = this.props;
const { remoteProjects, localProjects, pullingProjects } = this.state;
const missingRemoteProjects = remoteProjects.filter(({ id, rootDocumentId }) => {
const localProjectExists = localProjects.find(p => p.id === id);
const workspaceExists = workspaces.find(w => w._id === rootDocumentId);
// Mark as missing if:
// - the project doesn't yet exists locally
// - the project exists locally but somehow the workspace doesn't anymore
return !(workspaceExists && localProjectExists);
});
const nonActiveWorkspaces = workspaces
.filter(w => w._id !== activeWorkspace._id)
.sort((w1, w2) => w1.name.localeCompare(w2.name));
@@ -116,7 +227,8 @@ class WorkspaceDropdown extends PureComponent {
const unseenWorkspacesMessage = (
<div>
The following workspaces were added<br />
The following workspaces were added
<br />
{addedWorkspaceNames}
</div>
);
@@ -145,7 +257,7 @@ class WorkspaceDropdown extends PureComponent {
</h1>
</DropdownButton>
<DropdownDivider>{activeWorkspace.name}</DropdownDivider>
<DropdownItem onClick={this._handleShowWorkspaceSettings}>
<DropdownItem onClick={WorkspaceDropdown._handleShowWorkspaceSettings}>
<i className="fa fa-wrench" /> Workspace Settings
<DropdownHint keyBindings={hotKeyRegistry[hotKeyRefs.WORKSPACE_SHOW_SETTINGS.id]} />
</DropdownItem>
@@ -159,10 +271,10 @@ class WorkspaceDropdown extends PureComponent {
{nonActiveWorkspaces.map(w => {
const isUnseen = !!unseenWorkspaces.find(v => v._id === w._id);
return (
<DropdownItem key={w._id} onClick={this._handleSwitchWorkspace} value={w._id}>
<DropdownItem key={w._id} onClick={handleSetActiveWorkspace} value={w._id}>
<i className="fa fa-random" /> To <strong>{w.name}</strong>
{isUnseen && (
<Tooltip message="This workspace is new">
<Tooltip message="You haven't seen this workspace before" position="top">
<i className="width-auto fa fa-asterisk surprise" />
</Tooltip>
)}
@@ -171,28 +283,59 @@ class WorkspaceDropdown extends PureComponent {
})}
<DropdownItem onClick={this._handleWorkspaceCreate}>
<i className="fa fa-empty" /> New Workspace
<i className="fa fa-empty" /> Create Workspace
</DropdownItem>
{missingRemoteProjects.length > 0 && (
<DropdownDivider>
Remote Workspaces{' '}
<HelpTooltip>
These workspaces have been shared with you via Insomnia Sync and do not yet exist on
your machine.
</HelpTooltip>
</DropdownDivider>
)}
{missingRemoteProjects.map(p => (
<DropdownItem
key={p.id}
stayOpenAfterClick
onClick={() => this._handlePullRemoteWorkspace(p)}>
{pullingProjects[p.id] ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-download" />
)}
Pull <strong>{p.name}</strong>
</DropdownItem>
))}
<DropdownDivider>Insomnia Version {getAppVersion()}</DropdownDivider>
<DropdownItem onClick={this._handleShowSettings}>
<DropdownItem onClick={WorkspaceDropdown._handleShowSettings}>
<i className="fa fa-cog" /> Preferences
<DropdownHint keyBindings={hotKeyRegistry[hotKeyRefs.PREFERENCES_SHOW_GENERAL.id]} />
</DropdownItem>
<DropdownItem onClick={this._handleShowExport}>
<DropdownItem onClick={WorkspaceDropdown._handleShowExport}>
<i className="fa fa-share" /> Import/Export
</DropdownItem>
{/* Not Logged In */}
{!this.state.loggedIn && (
<DropdownItem key="login" onClick={this._handleShowLogin}>
{session.isLoggedIn() ? (
<DropdownItem
key="login"
onClick={WorkspaceDropdown._handleLogout}
buttonClass={PromptButton}>
<i className="fa fa-sign-out" /> Logout
</DropdownItem>
) : (
<DropdownItem key="login" onClick={() => showModal(LoginModal)}>
<i className="fa fa-sign-in" /> Log In
</DropdownItem>
)}
{!this.state.loggedIn && (
{!session.isLoggedIn() && (
<DropdownItem
key="invite"
buttonClass={Link}
@@ -208,19 +351,4 @@ class WorkspaceDropdown extends PureComponent {
}
}
WorkspaceDropdown.propTypes = {
// Required
isLoading: PropTypes.bool.isRequired,
handleImportFile: PropTypes.func.isRequired,
handleExportFile: PropTypes.func.isRequired,
handleSetActiveWorkspace: PropTypes.func.isRequired,
workspaces: PropTypes.arrayOf(PropTypes.object).isRequired,
unseenWorkspaces: PropTypes.arrayOf(PropTypes.object).isRequired,
activeWorkspace: PropTypes.object.isRequired,
hotKeyRegistry: PropTypes.object.isRequired,
// Optional
className: PropTypes.string,
};
export default WorkspaceDropdown;
@@ -127,11 +127,7 @@ class AsapAuth extends React.PureComponent<Props> {
}
_handleEditPrivateKey() {
const {
handleRender,
handleGetRenderContext,
request,
} = this.props;
const { handleRender, handleGetRenderContext, request } = this.props;
const { authentication } = request;
@@ -171,7 +167,7 @@ class AsapAuth extends React.PureComponent<Props> {
},
)}>
<button className="btn btn--clicky wide" onClick={this._handleEditPrivateKey}>
<i className="fa fa-edit space-right"/>
<i className="fa fa-edit space-right" />
{authentication.privateKey ? 'Click to Edit' : 'Click to Add'}
</button>
</div>
@@ -188,30 +184,30 @@ class AsapAuth extends React.PureComponent<Props> {
<div className="pad">
<table>
<tbody>
{fields}
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o"/>
) : (
<i className="fa fa-check-square-o"/>
)}
</Button>
</div>
</td>
</tr>
{fields}
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -160,7 +160,7 @@ class AuthWrapper extends React.PureComponent<Props> {
/>
);
} else if (authentication.type === AUTH_NETRC) {
return <NetrcAuth/>;
return <NetrcAuth />;
} else if (authentication.type === AUTH_ASAP) {
return (
<AsapAuth
@@ -176,9 +176,9 @@ class AuthWrapper extends React.PureComponent<Props> {
return (
<div className="vertically-center text-center">
<p className="pad super-faint text-sm text-center">
<i className="fa fa-unlock-alt" style={{ fontSize: '8rem', opacity: 0.3 }}/>
<br/>
<br/>
<i className="fa fa-unlock-alt" style={{ fontSize: '8rem', opacity: 0.3 }} />
<br />
<br />
Select an auth type from above
</p>
</div>
@@ -187,11 +187,7 @@ class AuthWrapper extends React.PureComponent<Props> {
}
render() {
return (
<div className="tall">
{this.renderEditor()}
</div>
);
return <div className="tall">{this.renderEditor()}</div>;
}
}
@@ -97,53 +97,53 @@ class AWSAuth extends React.PureComponent<Props> {
<div className="pad">
<table>
<tbody>
{this.renderRow('accessKeyId', 'Access Key ID', this._handleChangeAccessKeyId)}
{this.renderRow(
'secretAccessKey',
'Secret Access Key',
this._handleChangeSecretAccessKey,
)}
{this.renderRow(
'region',
'Region',
this._handleChangeRegion,
'will be calculated from hostname or host or use \'us-east-1\' if not given',
)}
{this.renderRow(
'service',
'Service',
this._handleChangeService,
'will be calculated from hostname or host if not given',
)}
{this.renderRow(
'sessionToken',
'Session Token',
this._handleChangeSessionToken,
'Optional token used for multi-factor authentication',
)}
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o"/>
) : (
<i className="fa fa-check-square-o"/>
)}
</Button>
</div>
</td>
</tr>
{this.renderRow('accessKeyId', 'Access Key ID', this._handleChangeAccessKeyId)}
{this.renderRow(
'secretAccessKey',
'Secret Access Key',
this._handleChangeSecretAccessKey,
)}
{this.renderRow(
'region',
'Region',
this._handleChangeRegion,
"will be calculated from hostname or host or use 'us-east-1' if not given",
)}
{this.renderRow(
'service',
'Service',
this._handleChangeService,
'will be calculated from hostname or host if not given',
)}
{this.renderRow(
'sessionToken',
'Session Token',
this._handleChangeSessionToken,
'Optional token used for multi-factor authentication',
)}
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -10,7 +10,7 @@ import type { Request, RequestAuthentication } from '../../../../models/request'
type Props = {
handleRender: Function,
handleGetRenderContext: Function,
handleUpdateSettingsShowPasswords: (boolean) => Promise<Settings>,
handleUpdateSettingsShowPasswords: boolean => Promise<Settings>,
nunjucksPowerUserMode: boolean,
onChange: (Request, RequestAuthentication) => Promise<Request>,
request: Request,
@@ -54,78 +54,78 @@ class BasicAuth extends React.PureComponent<Props> {
<div className="pad">
<table>
<tbody>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="username" className="label--small no-pad">
Username
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="username"
disabled={authentication.disabled}
onChange={this._handleChangeUsername}
defaultValue={authentication.username || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="password" className="label--small no-pad">
Password
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type={showPasswords ? 'text' : 'password'}
id="password"
onChange={this._handleChangePassword}
defaultValue={authentication.password || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o"/>
) : (
<i className="fa fa-check-square-o"/>
)}
</Button>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="username" className="label--small no-pad">
Username
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="username"
disabled={authentication.disabled}
onChange={this._handleChangeUsername}
defaultValue={authentication.username || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="password" className="label--small no-pad">
Password
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type={showPasswords ? 'text' : 'password'}
id="password"
onChange={this._handleChangePassword}
defaultValue={authentication.password || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -51,82 +51,82 @@ class BearerAuth extends React.PureComponent<Props> {
<div className="pad">
<table>
<tbody>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="token" className="label--small no-pad">
Token
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="token"
disabled={authentication.disabled}
onChange={this._handleChangeToken}
defaultValue={authentication.token || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="prefix" className="label--small no-pad">
Prefix{' '}
<HelpTooltip>
Prefix to use when sending the Authorization header. Defaults to Bearer.
</HelpTooltip>
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="prefix"
disabled={authentication.disabled}
onChange={this._handleChangePrefix}
defaultValue={authentication.prefix || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o"/>
) : (
<i className="fa fa-check-square-o"/>
)}
</Button>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="token" className="label--small no-pad">
Token
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="token"
disabled={authentication.disabled}
onChange={this._handleChangeToken}
defaultValue={authentication.token || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="prefix" className="label--small no-pad">
Prefix{' '}
<HelpTooltip>
Prefix to use when sending the Authorization header. Defaults to Bearer.
</HelpTooltip>
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="prefix"
disabled={authentication.disabled}
onChange={this._handleChangePrefix}
defaultValue={authentication.prefix || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -11,7 +11,7 @@ import type { Request, RequestAuthentication } from '../../../../models/request'
type Props = {
handleRender: Function,
handleGetRenderContext: Function,
handleUpdateSettingsShowPasswords: (boolean) => Promise<Settings>,
handleUpdateSettingsShowPasswords: boolean => Promise<Settings>,
nunjucksPowerUserMode: boolean,
onChange: (Request, RequestAuthentication) => Promise<Request>,
request: Request,
@@ -55,78 +55,78 @@ class DigestAuth extends React.PureComponent<Props> {
<div className="pad">
<table>
<tbody>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="username" className="label--small no-pad">
Username
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="username"
disabled={authentication.disabled}
onChange={this._handleChangeUsername}
defaultValue={authentication.username || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="password" className="label--small no-pad">
Password
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type={showPasswords ? 'text' : 'password'}
id="password"
onChange={this._handleChangePassword}
defaultValue={authentication.password || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o"/>
) : (
<i className="fa fa-check-square-o"/>
)}
</Button>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="username" className="label--small no-pad">
Username
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="username"
disabled={authentication.disabled}
onChange={this._handleChangeUsername}
defaultValue={authentication.username || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="password" className="label--small no-pad">
Password
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type={showPasswords ? 'text' : 'password'}
id="password"
onChange={this._handleChangePassword}
defaultValue={authentication.password || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -10,7 +10,7 @@ import type { Settings } from '../../../../models/settings';
type Props = {
handleRender: Function,
handleGetRenderContext: Function,
handleUpdateSettingsShowPasswords: (boolean) => Promise<Settings>,
handleUpdateSettingsShowPasswords: boolean => Promise<Settings>,
nunjucksPowerUserMode: boolean,
onChange: (Request, RequestAuthentication) => Promise<Request>,
request: Request,
@@ -54,78 +54,78 @@ class NTLMAuth extends React.PureComponent<Props> {
<div className="pad">
<table>
<tbody>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="username" className="label--small no-pad">
Username
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="username"
disabled={authentication.disabled}
onChange={this._handleChangeUsername}
defaultValue={authentication.username || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="password" className="label--small no-pad">
Password
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type={showPasswords ? 'text' : 'password'}
id="password"
onChange={this._handleChangePassword}
defaultValue={authentication.password || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o"/>
) : (
<i className="fa fa-check-square-o"/>
)}
</Button>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="username" className="label--small no-pad">
Username
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type="text"
id="username"
disabled={authentication.disabled}
onChange={this._handleChangeUsername}
defaultValue={authentication.username || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="password" className="label--small no-pad">
Password
</label>
</td>
<td className="wide">
<div
className={classnames('form-control form-control--underlined no-margin', {
'form-control--inactive': authentication.disabled,
})}>
<OneLineEditor
type={showPasswords ? 'text' : 'password'}
id="password"
onChange={this._handleChangePassword}
defaultValue={authentication.password || ''}
nunjucksPowerUserMode={nunjucksPowerUserMode}
render={handleRender}
getRenderContext={handleGetRenderContext}
isVariableUncovered={isVariableUncovered}
/>
</div>
</td>
</tr>
<tr>
<td className="pad-right no-wrap valign-middle">
<label htmlFor="enabled" className="label--small no-pad">
Enabled
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined">
<Button
className="btn btn--super-duper-compact"
id="enabled"
onClick={this._handleDisable}
value={!authentication.disabled}
title={authentication.disabled ? 'Enable item' : 'Disable item'}>
{authentication.disabled ? (
<i className="fa fa-square-o" />
) : (
<i className="fa fa-check-square-o" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
@@ -31,7 +31,7 @@ import type { Settings } from '../../../../models/settings';
type Props = {
handleRender: Function,
handleGetRenderContext: Function,
handleUpdateSettingsShowPasswords: (boolean) => Promise<Settings>,
handleUpdateSettingsShowPasswords: boolean => Promise<Settings>,
nunjucksPowerUserMode: boolean,
onChange: (Request, RequestAuthentication) => Promise<Request>,
request: Request,
@@ -456,7 +456,8 @@ class OAuth2Auth extends React.PureComponent<Props, State> {
return (
<span>
&#x28;expires <TimeFromNow timestamp={token.expiresAt} />&#x29;
&#x28;expires <TimeFromNow timestamp={token.expiresAt} />
&#x29;
</span>
);
}
@@ -586,8 +587,8 @@ class OAuth2Auth extends React.PureComponent<Props, State> {
? 'Refreshing...'
: 'Fetching...'
: tok
? 'Refresh Token'
: 'Fetch Tokens'}
? 'Refresh Token'
: 'Fetch Tokens'}
</button>
</div>
</div>
@@ -14,7 +14,12 @@ import {
CONTENT_TYPE_GRAPHQL,
getContentTypeFromHeaders,
} from '../../../../common/constants';
import type { Request, RequestBody, RequestBodyParameter, RequestHeader } from '../../../../models/request';
import type {
Request,
RequestBody,
RequestBodyParameter,
RequestHeader,
} from '../../../../models/request';
import {
newBodyFile,
newBodyForm,
@@ -68,7 +68,6 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
_isMounted: boolean;
_queryEditor: null | CodeMirror;
_schemaFetchTimeout: TimeoutID;
_highlightTimeout: TimeoutID;
constructor(props: Props) {
super(props);
@@ -338,7 +337,7 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
}
}
_getOperations() {
_getOperations(): Array<any> {
if (!this._documentAST) {
return [];
}
@@ -347,12 +346,6 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
}
_handleBodyChange(query: string, variables: ?Object, operationName: ?string): void {
try {
this._documentAST = parse(query);
} catch (e) {
this._documentAST = null;
}
const body: GraphQLBody = { query };
if (variables) {
@@ -363,13 +356,27 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
body.operationName = operationName;
}
const newContent = GraphQLEditor._graphQLToString(body);
// This method gets called a lot so make sure we only do something if the
// new body has actually changed.
if (this.props.content === newContent) {
return;
}
this.setState({
variablesSyntaxError: '',
body,
});
this.props.onChange(GraphQLEditor._graphQLToString(body));
this.props.onChange(newContent);
this._highlightOperation(body.operationName || null);
try {
this._documentAST = parse(query);
} catch (e) {
this._documentAST = null;
}
}
_handleQueryChange(query: string): void {
@@ -551,23 +558,22 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
/>
</div>
<div className="graphql-editor__schema-error">
{!hideSchemaFetchErrors &&
schemaFetchError && (
<div className="notice error margin no-margin-top margin-bottom-sm">
<div className="pull-right">
<Tooltip position="top" message="View introspection request/response timeline">
<button className="icon icon--success" onClick={this._handleViewResponse}>
<i className="fa fa-bug" />
</button>
</Tooltip>{' '}
<button className="icon" onClick={this._hideSchemaFetchError}>
<i className="fa fa-times" />
{!hideSchemaFetchErrors && schemaFetchError && (
<div className="notice error margin no-margin-top margin-bottom-sm">
<div className="pull-right">
<Tooltip position="top" message="View introspection request/response timeline">
<button className="icon icon--success" onClick={this._handleViewResponse}>
<i className="fa fa-bug" />
</button>
</div>
{schemaFetchError.message}
<br />
</Tooltip>{' '}
<button className="icon" onClick={this._hideSchemaFetchError}>
<i className="fa fa-times" />
</button>
</div>
)}
{schemaFetchError.message}
<br />
</div>
)}
</div>
<div className="graphql-editor__meta">
{this.renderSchemaFetchMessage()}
@@ -576,7 +582,8 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
<h2 className="no-margin pad-left-sm pad-top-sm pad-bottom-sm">
Query Variables
<HelpTooltip className="space-left">
Variables to use in GraphQL query <br />(JSON format)
Variables to use in GraphQL query <br />
(JSON format)
</HelpTooltip>
{variablesSyntaxError && (
<span className="text-danger italic pull-right">{variablesSyntaxError}</span>
@@ -68,7 +68,7 @@ class SingleErrorBoundary extends React.PureComponent<Props, State> {
}
class ErrorBoundary extends React.PureComponent<Props> {
render() {
render(): React.Node {
const { children, ...extraProps } = this.props;
if (!children) {
@@ -0,0 +1,84 @@
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import classnames from 'classnames';
import type { RequestGroup } from '../../../models/request-group';
type Props = {
// Required.
handleSetRequestGroupCollapsed: Function,
handleSetItemSelected: Function,
isCollapsed: boolean,
totalRequests: number,
selectedRequests: number,
requestGroup: RequestGroup,
// Optional.
children?: React.Node,
};
@autobind
class RequestGroupRow extends React.PureComponent<Props> {
checkbox: HTMLInputElement;
setCheckboxRef(checkbox: ?HTMLInputElement) {
if (checkbox != null) {
this.checkbox = checkbox;
}
}
handleCollapse() {
const { requestGroup, handleSetRequestGroupCollapsed, isCollapsed } = this.props;
handleSetRequestGroupCollapsed(requestGroup._id, !isCollapsed);
}
handleSelect(e: SyntheticEvent<HTMLInputElement>) {
const el = e.currentTarget;
let value = el.checked;
const { handleSetItemSelected, requestGroup } = this.props;
return handleSetItemSelected(requestGroup._id, value);
}
render() {
const { children, requestGroup, isCollapsed, totalRequests, selectedRequests } = this.props;
let folderIconClass = 'fa-folder';
folderIconClass += isCollapsed ? '' : '-open';
const isSelected = selectedRequests === totalRequests;
if (this.checkbox != null) {
// Partial or indeterminate checkbox.
this.checkbox.indeterminate = selectedRequests > 0 && selectedRequests < totalRequests;
}
return (
<li key={requestGroup._id} className="tree__row">
<div className="tree__item tree__item--big">
<div className="tree__item__checkbox tree__indent">
<input
ref={this.setCheckboxRef}
type="checkbox"
checked={isSelected}
onChange={this.handleSelect}
/>
</div>
<button onClick={this.handleCollapse}>
<i className={'tree__item__icon fa ' + folderIconClass} />
{requestGroup.name}
<span className="total-requests">{totalRequests} requests</span>
</button>
</div>
<ul
className={classnames('tree__list', {
'tree__list--collapsed': isCollapsed,
})}>
{!isCollapsed ? children : null}
</ul>
</li>
);
}
}
export default RequestGroupRow;
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more