summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/ide/lib
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/ide/lib')
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js35
-rw-r--r--app/assets/javascripts/ide/lib/create_diff.js85
-rw-r--r--app/assets/javascripts/ide/lib/create_file_diff.js112
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js9
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js9
-rw-r--r--app/assets/javascripts/ide/lib/editor.js32
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js22
-rw-r--r--app/assets/javascripts/ide/lib/editorconfig/parser.js55
-rw-r--r--app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js33
-rw-r--r--app/assets/javascripts/ide/lib/files.js5
-rw-r--r--app/assets/javascripts/ide/lib/languages/README.md21
-rw-r--r--app/assets/javascripts/ide/lib/mirror.js154
12 files changed, 549 insertions, 23 deletions
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index a15f04075d9..c5bb00c3dee 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -1,6 +1,8 @@
import { editor as monacoEditor, Uri } from 'monaco-editor';
import Disposable from './disposable';
import eventHub from '../../eventhub';
+import { trimTrailingWhitespace, insertFinalNewline } from '../../utils';
+import { defaultModelOptions } from '../editor_options';
export default class Model {
constructor(file, head = null) {
@@ -8,6 +10,7 @@ export default class Model {
this.file = file;
this.head = head;
this.content = file.content !== '' || file.deleted ? file.content : file.raw;
+ this.options = { ...defaultModelOptions };
this.disposable.add(
(this.originalModel = monacoEditor.createModel(
@@ -50,10 +53,6 @@ export default class Model {
return this.model.getModeId();
}
- get eol() {
- return this.model.getEOL() === '\n' ? 'LF' : 'CRLF';
- }
-
get path() {
return this.file.key;
}
@@ -94,8 +93,32 @@ export default class Model {
this.getModel().setValue(content);
}
+ updateOptions(obj = {}) {
+ Object.assign(this.options, obj);
+ this.model.updateOptions(obj);
+ this.applyCustomOptions();
+ }
+
+ applyCustomOptions() {
+ this.updateNewContent(
+ Object.entries(this.options).reduce((content, [key, value]) => {
+ switch (key) {
+ case 'endOfLine':
+ this.model.pushEOL(value);
+ return this.model.getValue();
+ case 'insertFinalNewline':
+ return value ? insertFinalNewline(content) : content;
+ case 'trimTrailingWhitespace':
+ return value ? trimTrailingWhitespace(content) : content;
+ default:
+ return content;
+ }
+ }, this.model.getValue()),
+ );
+ }
+
dispose() {
- this.disposable.dispose();
+ if (!this.model.isDisposed()) this.applyCustomOptions();
this.events.forEach(cb => {
if (typeof cb === 'function') cb();
@@ -106,5 +129,7 @@ export default class Model {
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
+
+ this.disposable.dispose();
}
}
diff --git a/app/assets/javascripts/ide/lib/create_diff.js b/app/assets/javascripts/ide/lib/create_diff.js
new file mode 100644
index 00000000000..3e915afdbcb
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/create_diff.js
@@ -0,0 +1,85 @@
+import { commitActionForFile } from '~/ide/stores/utils';
+import { commitActionTypes } from '~/ide/constants';
+import createFileDiff from './create_file_diff';
+
+const getDeletedParents = (entries, file) => {
+ const parent = file.parentPath && entries[file.parentPath];
+
+ if (parent && parent.deleted) {
+ return [parent, ...getDeletedParents(entries, parent)];
+ }
+
+ return [];
+};
+
+const filesWithChanges = ({ stagedFiles = [], changedFiles = [], entries = {} }) => {
+ // We need changed files to overwrite staged, so put them at the end.
+ const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => {
+ const key = file.path;
+ const action = commitActionForFile(file);
+ const prev = acc[key];
+
+ // If a file was deleted, which was previously added, then we should do nothing.
+ if (action === commitActionTypes.delete && prev && prev.action === commitActionTypes.create) {
+ delete acc[key];
+ } else {
+ acc[key] = { action, file };
+ }
+
+ return acc;
+ }, {});
+
+ // We need to clean "move" actions, because we can only support 100% similarity moves at the moment.
+ // This is because the previous file's content might not be loaded.
+ Object.values(changes)
+ .filter(change => change.action === commitActionTypes.move)
+ .forEach(change => {
+ const prev = changes[change.file.prevPath];
+
+ if (!prev) {
+ return;
+ }
+
+ if (change.file.content === prev.file.content) {
+ // If content is the same, continue with the move but don't do the prevPath's delete.
+ delete changes[change.file.prevPath];
+ } else {
+ // Otherwise, treat the move as a delete / create.
+ Object.assign(change, { action: commitActionTypes.create });
+ }
+ });
+
+ // Next, we need to add deleted directories by looking at the parents
+ Object.values(changes)
+ .filter(change => change.action === commitActionTypes.delete && change.file.parentPath)
+ .forEach(({ file }) => {
+ // Do nothing if we've already visited this directory.
+ if (changes[file.parentPath]) {
+ return;
+ }
+
+ getDeletedParents(entries, file).forEach(parent => {
+ changes[parent.path] = { action: commitActionTypes.delete, file: parent };
+ });
+ });
+
+ return Object.values(changes);
+};
+
+const createDiff = state => {
+ const changes = filesWithChanges(state);
+
+ const toDelete = changes.filter(x => x.action === commitActionTypes.delete).map(x => x.file.path);
+
+ const patch = changes
+ .filter(x => x.action !== commitActionTypes.delete)
+ .map(({ file, action }) => createFileDiff(file, action))
+ .join('');
+
+ return {
+ patch,
+ toDelete,
+ };
+};
+
+export default createDiff;
diff --git a/app/assets/javascripts/ide/lib/create_file_diff.js b/app/assets/javascripts/ide/lib/create_file_diff.js
new file mode 100644
index 00000000000..5ae4993321c
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/create_file_diff.js
@@ -0,0 +1,112 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import { createTwoFilesPatch } from 'diff';
+import { commitActionTypes } from '~/ide/constants';
+
+const DEV_NULL = '/dev/null';
+const DEFAULT_MODE = '100644';
+const NO_NEW_LINE = '\\ No newline at end of file';
+const NEW_LINE = '\n';
+
+/**
+ * Cleans patch generated by `diff` package.
+ *
+ * - Removes "=======" separator added at the beginning
+ */
+const cleanTwoFilesPatch = text => text.replace(/^(=+\s*)/, '');
+
+const endsWithNewLine = val => !val || val[val.length - 1] === NEW_LINE;
+
+const addEndingNewLine = val => (endsWithNewLine(val) ? val : val + NEW_LINE);
+
+const removeEndingNewLine = val => (endsWithNewLine(val) ? val.substr(0, val.length - 1) : val);
+
+const diffHead = (prevPath, newPath = '') =>
+ `diff --git "a/${prevPath}" "b/${newPath || prevPath}"`;
+
+const createDiffBody = (path, content, isCreate) => {
+ if (!content) {
+ return '';
+ }
+
+ const prefix = isCreate ? '+' : '-';
+ const fromPath = isCreate ? DEV_NULL : `a/${path}`;
+ const toPath = isCreate ? `b/${path}` : DEV_NULL;
+
+ const hasNewLine = endsWithNewLine(content);
+ const lines = removeEndingNewLine(content).split(NEW_LINE);
+
+ const chunkHead = isCreate ? `@@ -0,0 +1,${lines.length} @@` : `@@ -1,${lines.length} +0,0 @@`;
+ const chunk = lines
+ .map(line => `${prefix}${line}`)
+ .concat(!hasNewLine ? [NO_NEW_LINE] : [])
+ .join(NEW_LINE);
+
+ return `--- ${fromPath}
++++ ${toPath}
+${chunkHead}
+${chunk}`;
+};
+
+const createMoveFileDiff = (prevPath, newPath) => `${diffHead(prevPath, newPath)}
+rename from ${prevPath}
+rename to ${newPath}`;
+
+const createNewFileDiff = (path, content) => {
+ const diff = createDiffBody(path, content, true);
+
+ return `${diffHead(path)}
+new file mode ${DEFAULT_MODE}
+${diff}`;
+};
+
+const createDeleteFileDiff = (path, content) => {
+ const diff = createDiffBody(path, content, false);
+
+ return `${diffHead(path)}
+deleted file mode ${DEFAULT_MODE}
+${diff}`;
+};
+
+const createUpdateFileDiff = (path, oldContent, newContent) => {
+ const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent);
+
+ return `${diffHead(path)}
+${cleanTwoFilesPatch(patch)}`;
+};
+
+const createFileDiffRaw = (file, action) => {
+ switch (action) {
+ case commitActionTypes.move:
+ return createMoveFileDiff(file.prevPath, file.path);
+ case commitActionTypes.create:
+ return createNewFileDiff(file.path, file.content);
+ case commitActionTypes.delete:
+ return createDeleteFileDiff(file.path, file.content);
+ case commitActionTypes.update:
+ return createUpdateFileDiff(file.path, file.raw || '', file.content);
+ default:
+ return '';
+ }
+};
+
+/**
+ * Create a git diff for a single IDE file.
+ *
+ * ## Notes:
+ * When called with `commitActionType.move`, it assumes that the move
+ * is a 100% similarity move. No diff will be generated. This is because
+ * generating a move with changes is not support by the current IDE, since
+ * the source file might not have it's content loaded yet.
+ *
+ * When called with `commitActionType.delete`, it does not support
+ * deleting files with a mode different than 100644. For the IDE mirror, this
+ * isn't needed because deleting is handled outside the unified patch.
+ *
+ * ## References:
+ * - https://git-scm.com/docs/git-diff#_generating_patches_with_p
+ */
+const createFileDiff = (file, action) =>
+ // It's important that the file diff ends in a new line - git expects this.
+ addEndingNewLine(createFileDiffRaw(file, action));
+
+export default createFileDiff;
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
index 234a7f903a1..35fcda6a6c5 100644
--- a/app/assets/javascripts/ide/lib/diff/controller.js
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -50,10 +50,15 @@ export default class DirtyDiffController {
}
computeDiff(model) {
+ const originalModel = model.getOriginalModel();
+ const newModel = model.getModel();
+
+ if (originalModel.isDisposed() || newModel.isDisposed()) return;
+
this.dirtyDiffWorker.postMessage({
path: model.path,
- originalContent: model.getOriginalModel().getValue(),
- newContent: model.getModel().getValue(),
+ originalContent: originalModel.getValue(),
+ newContent: newModel.getValue(),
});
}
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
index 29e29d7fcd3..3a456b7c4d6 100644
--- a/app/assets/javascripts/ide/lib/diff/diff.js
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -1,8 +1,15 @@
import { diffLines } from 'diff';
+import { defaultDiffOptions } from '../editor_options';
+// See: https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20
// eslint-disable-next-line import/prefer-default-export
export const computeDiff = (originalContent, newContent) => {
- const changes = diffLines(originalContent, newContent);
+ // prevent EOL changes from highlighting the entire file
+ const changes = diffLines(
+ originalContent.replace(/\r\n/g, '\n'),
+ newContent.replace(/\r\n/g, '\n'),
+ defaultDiffOptions,
+ );
let lineNumber = 1;
return changes.reduce((acc, change) => {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 25224abd77c..4dfc27117c0 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -1,11 +1,11 @@
import { debounce } from 'lodash';
-import { editor as monacoEditor, KeyCode, KeyMod } from 'monaco-editor';
+import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor';
import store from '../stores';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
-import editorOptions, { defaultEditorOptions } from './editor_options';
+import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options';
import { themes } from './themes';
import languages from './languages';
import keymap from './keymap.json';
@@ -37,6 +37,10 @@ export default class Editor {
...defaultEditorOptions,
...options,
};
+ this.diffOptions = {
+ ...defaultDiffEditorOptions,
+ ...options,
+ };
setupThemes();
registerLanguages(...languages);
@@ -66,19 +70,14 @@ export default class Editor {
}
}
- createDiffInstance(domElement, readOnly = true) {
+ createDiffInstance(domElement) {
if (!this.instance) {
clearDomElement(domElement);
this.disposable.add(
(this.instance = monacoEditor.createDiffEditor(domElement, {
- ...this.options,
- quickSuggestions: false,
- occurrencesHighlight: false,
+ ...this.diffOptions,
renderSideBySide: Editor.renderSideBySide(domElement),
- readOnly,
- renderLineHighlight: readOnly ? 'all' : 'none',
- hideCursorInOverviewRuler: !readOnly,
})),
);
@@ -187,6 +186,21 @@ export default class Editor {
});
}
+ replaceSelectedText(text) {
+ let selection = this.instance.getSelection();
+ const range = new Range(
+ selection.startLineNumber,
+ selection.startColumn,
+ selection.endLineNumber,
+ selection.endColumn,
+ );
+
+ this.instance.executeEdits('', [{ range, text }]);
+
+ selection = this.instance.getSelection();
+ this.instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
+ }
+
get isDiffEditorType() {
return this.instance.getEditorType() === 'vs.editor.IDiffEditor';
}
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index dac2a8e8b51..f182a1ec50e 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -9,7 +9,27 @@ export const defaultEditorOptions = {
wordWrap: 'on',
};
-export default [
+export const defaultDiffOptions = {
+ ignoreWhitespace: false,
+};
+
+export const defaultDiffEditorOptions = {
+ ...defaultEditorOptions,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ ignoreTrimWhitespace: false,
+ readOnly: false,
+ renderLineHighlight: 'none',
+ hideCursorInOverviewRuler: true,
+};
+
+export const defaultModelOptions = {
+ endOfLine: 0,
+ insertFinalNewline: true,
+ trimTrailingWhitespace: false,
+};
+
+export const editorOptions = [
{
readOnly: model => Boolean(model.file.file_lock),
quickSuggestions: model => !(model.language === 'markdown'),
diff --git a/app/assets/javascripts/ide/lib/editorconfig/parser.js b/app/assets/javascripts/ide/lib/editorconfig/parser.js
new file mode 100644
index 00000000000..a30a8cb868d
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editorconfig/parser.js
@@ -0,0 +1,55 @@
+import { parseString } from 'editorconfig/src/lib/ini';
+import minimatch from 'minimatch';
+import { getPathParents } from '../../utils';
+
+const dirname = path => path.replace(/\.editorconfig$/, '');
+
+function isRootConfig(config) {
+ return config.some(([pattern, rules]) => !pattern && rules?.root === 'true');
+}
+
+function getRulesForSection(path, [pattern, rules]) {
+ if (!pattern) {
+ return {};
+ }
+ if (minimatch(path, pattern, { matchBase: true })) {
+ return rules;
+ }
+
+ return {};
+}
+
+function getRulesWithConfigs(filePath, configFiles = [], rules = {}) {
+ if (!configFiles.length) return rules;
+
+ const [{ content, path: configPath }, ...nextConfigs] = configFiles;
+ const configDir = dirname(configPath);
+
+ if (!filePath.startsWith(configDir)) return rules;
+
+ const parsed = parseString(content);
+ const isRoot = isRootConfig(parsed);
+ const relativeFilePath = filePath.slice(configDir.length);
+
+ const sectionRules = parsed.reduce(
+ (acc, section) => Object.assign(acc, getRulesForSection(relativeFilePath, section)),
+ {},
+ );
+
+ // prefer existing rules by overwriting to section rules
+ const result = Object.assign(sectionRules, rules);
+
+ return isRoot ? result : getRulesWithConfigs(filePath, nextConfigs, result);
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export function getRulesWithTraversal(filePath, getFileContent) {
+ const editorconfigPaths = [
+ ...getPathParents(filePath).map(x => `${x}/.editorconfig`),
+ '.editorconfig',
+ ];
+
+ return Promise.all(
+ editorconfigPaths.map(path => getFileContent(path).then(content => ({ path, content }))),
+ ).then(results => getRulesWithConfigs(filePath, results.filter(x => x.content)));
+}
diff --git a/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js
new file mode 100644
index 00000000000..f9d5579511a
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js
@@ -0,0 +1,33 @@
+import { isBoolean, isNumber } from 'lodash';
+
+const map = (key, validValues) => value =>
+ value in validValues ? { [key]: validValues[value] } : {};
+
+const bool = key => value => (isBoolean(value) ? { [key]: value } : {});
+
+const int = (key, isValid) => value =>
+ isNumber(value) && isValid(value) ? { [key]: Math.trunc(value) } : {};
+
+const rulesMapper = {
+ indent_style: map('insertSpaces', { tab: false, space: true }),
+ indent_size: int('tabSize', n => n > 0),
+ tab_width: int('tabSize', n => n > 0),
+ trim_trailing_whitespace: bool('trimTrailingWhitespace'),
+ end_of_line: map('endOfLine', { crlf: 1, lf: 0 }),
+ insert_final_newline: bool('insertFinalNewline'),
+};
+
+const parseValue = x => {
+ let value = typeof x === 'string' ? x.toLowerCase() : x;
+ if (/^[0-9.-]+$/.test(value)) value = Number(value);
+ if (value === 'true') value = true;
+ if (value === 'false') value = false;
+
+ return value;
+};
+
+export default function mapRulesToMonaco(rules) {
+ return Object.entries(rules).reduce((obj, [key, value]) => {
+ return Object.assign(obj, rulesMapper[key]?.(parseValue(value)) || {});
+ }, {});
+}
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
index 26518a2abac..6d85e225fd5 100644
--- a/app/assets/javascripts/ide/lib/files.js
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -19,7 +19,6 @@ export const decorateFiles = ({
branchId,
tempFile = false,
content = '',
- base64 = false,
binary = false,
rawPath = '',
}) => {
@@ -49,7 +48,6 @@ export const decorateFiles = ({
path,
url: `/${projectId}/tree/${branchId}/-/${path}/`,
type: 'tree',
- parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
tempFile,
changed: tempFile,
opened: tempFile,
@@ -86,14 +84,11 @@ export const decorateFiles = ({
path,
url: `/${projectId}/blob/${branchId}/-/${path}`,
type: 'blob',
- parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
tempFile,
changed: tempFile,
content,
- base64,
binary: (previewMode && previewMode.binary) || binary,
rawPath,
- previewMode,
parentPath,
});
diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md
new file mode 100644
index 00000000000..e4d1a4c7818
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/README.md
@@ -0,0 +1,21 @@
+# Web IDE Languages
+
+The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting.
+The Web IDE currently supports all langauges defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
+
+## Adding New Languages
+
+While Monaco supports a wide variety of languages, there's always the chance that it's missing something.
+You'll find a list of [unsupported languages in this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), which is the right place to add more if needed.
+
+Should you be willing to help us and add support to GitLab for any missing languages, here are the steps to do so:
+
+1. Create a new issue and add it to [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), if it doesn't already exist.
+2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for.
+3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language.
+ - Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting.
+4. Add tests for the new langauge implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`.
+ - Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js).
+5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language.
+
+Thank you!
diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js
new file mode 100644
index 00000000000..a516c28ad7a
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/mirror.js
@@ -0,0 +1,154 @@
+import createDiff from './create_diff';
+import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+
+export const SERVICE_NAME = 'webide-file-sync';
+export const PROTOCOL = 'webfilesync.gitlab.com';
+export const MSG_CONNECTION_ERROR = __('Could not connect to Web IDE file mirror service.');
+
+// Before actually connecting to the service, we must delay a bit
+// so that the service has sufficiently started.
+
+const noop = () => {};
+export const SERVICE_DELAY = 8000;
+
+const cancellableWait = time => {
+ let timeoutId = 0;
+
+ const cancel = () => clearTimeout(timeoutId);
+
+ const promise = new Promise(resolve => {
+ timeoutId = setTimeout(resolve, time);
+ });
+
+ return [promise, cancel];
+};
+
+const isErrorResponse = error => error && error.code !== 0;
+
+const isErrorPayload = payload => payload && payload.status_code !== 200;
+
+const getErrorFromResponse = data => {
+ if (isErrorResponse(data.error)) {
+ return { message: data.error.Message };
+ } else if (isErrorPayload(data.payload)) {
+ return { message: data.payload.error_message };
+ }
+
+ return null;
+};
+
+const getFullPath = path => mergeUrlParams({ service: SERVICE_NAME }, getWebSocketUrl(path));
+
+const createWebSocket = fullPath =>
+ new Promise((resolve, reject) => {
+ const socket = new WebSocket(fullPath, [PROTOCOL]);
+ const resetCallbacks = () => {
+ socket.onopen = null;
+ socket.onerror = null;
+ };
+
+ socket.onopen = () => {
+ resetCallbacks();
+ resolve(socket);
+ };
+
+ socket.onerror = () => {
+ resetCallbacks();
+ reject(new Error(MSG_CONNECTION_ERROR));
+ };
+ });
+
+export const canConnect = ({ services = [] }) => services.some(name => name === SERVICE_NAME);
+
+export const createMirror = () => {
+ let socket = null;
+ let cancelHandler = noop;
+ let nextMessageHandler = noop;
+
+ const cancelConnect = () => {
+ cancelHandler();
+ cancelHandler = noop;
+ };
+
+ const onCancelConnect = fn => {
+ cancelHandler = fn;
+ };
+
+ const receiveMessage = ev => {
+ const handle = nextMessageHandler;
+ nextMessageHandler = noop;
+ handle(JSON.parse(ev.data));
+ };
+
+ const onNextMessage = fn => {
+ nextMessageHandler = fn;
+ };
+
+ const waitForNextMessage = () =>
+ new Promise((resolve, reject) => {
+ onNextMessage(data => {
+ const err = getErrorFromResponse(data);
+
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+
+ const uploadDiff = ({ toDelete, patch }) => {
+ if (!socket) {
+ return Promise.resolve();
+ }
+
+ const response = waitForNextMessage();
+
+ const msg = {
+ code: 'EVENT',
+ namespace: '/files',
+ event: 'PATCH',
+ payload: { diff: patch, delete_files: toDelete },
+ };
+
+ socket.send(JSON.stringify(msg));
+
+ return response;
+ };
+
+ return {
+ upload(state) {
+ return uploadDiff(createDiff(state));
+ },
+ connect(path) {
+ if (socket) {
+ this.disconnect();
+ }
+
+ const fullPath = getFullPath(path);
+ const [wait, cancelWait] = cancellableWait(SERVICE_DELAY);
+
+ onCancelConnect(cancelWait);
+
+ return wait
+ .then(() => createWebSocket(fullPath))
+ .then(newSocket => {
+ socket = newSocket;
+ socket.onmessage = receiveMessage;
+ });
+ },
+ disconnect() {
+ cancelConnect();
+
+ if (!socket) {
+ return;
+ }
+
+ socket.close();
+ socket = null;
+ },
+ };
+};
+
+export default createMirror();