diff --git a/package.json b/package.json
index 5f7c78ec3dca2..8efd4ad17e644 100644
--- a/package.json
+++ b/package.json
@@ -207,7 +207,8 @@
"react-color": "^2.13.8",
"react-dom": "^16.8.0",
"react-grid-layout": "^0.16.2",
- "react-markdown": "^3.1.4",
+ "react-input-range": "^1.3.0",
+ "react-markdown": "^3.4.1",
"react-redux": "^5.0.7",
"react-router-dom": "^4.3.1",
"react-sizeme": "^2.3.6",
@@ -272,6 +273,7 @@
"@types/bluebird": "^3.1.1",
"@types/boom": "^7.2.0",
"@types/chance": "^1.0.0",
+ "@types/cheerio": "^0.22.10",
"@types/chromedriver": "^2.38.0",
"@types/classnames": "^2.2.3",
"@types/d3": "^3.5.41",
@@ -284,7 +286,7 @@
"@types/execa": "^0.9.0",
"@types/fetch-mock": "7.2.1",
"@types/getopts": "^2.0.1",
- "@types/glob": "^5.0.35",
+ "@types/glob": "^7.1.1",
"@types/globby": "^8.0.0",
"@types/graphql": "^0.13.1",
"@types/hapi": "^17.0.18",
@@ -320,7 +322,7 @@
"@types/rimraf": "^2.0.2",
"@types/selenium-webdriver": "^3.0.15",
"@types/semver": "^5.5.0",
- "@types/sinon": "^5.0.1",
+ "@types/sinon": "^7.0.0",
"@types/strip-ansi": "^3.0.0",
"@types/styled-components": "^3.0.1",
"@types/supertest": "^2.0.5",
@@ -398,7 +400,7 @@
"multistream": "^2.1.1",
"murmurhash3js": "3.0.1",
"mutation-observer": "^1.0.3",
- "nock": "8.0.0",
+ "nock": "10.0.4",
"node-sass": "^4.9.4",
"normalize-path": "^3.0.0",
"pixelmatch": "4.0.2",
@@ -412,7 +414,7 @@
"sass-lint": "^1.12.1",
"selenium-webdriver": "^4.0.0-alpha.1",
"simple-git": "1.37.0",
- "sinon": "^5.0.7",
+ "sinon": "^7.2.2",
"strip-ansi": "^3.0.1",
"supertest": "^3.1.0",
"supertest-as-promised": "^4.0.2",
diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json
index d13e8d6a26d27..8f83f30a8e36a 100644
--- a/packages/kbn-ui-framework/package.json
+++ b/packages/kbn-ui-framework/package.json
@@ -65,7 +65,7 @@
"redux": "3.7.2",
"redux-thunk": "2.2.0",
"sass-loader": "^7.1.0",
- "sinon": "^5.0.7",
+ "sinon": "^7.2.2",
"style-loader": "^0.23.1",
"webpack": "^4.23.1",
"webpack-dev-server": "^3.1.10",
diff --git a/src/cli/cluster/cluster_manager.js b/src/cli/cluster/cluster_manager.js
index 2ccc370796d59..c4ebe4cc3c674 100644
--- a/src/cli/cluster/cluster_manager.js
+++ b/src/cli/cluster/cluster_manager.js
@@ -181,6 +181,7 @@ export default class ClusterManager {
/[\\\/](\..*|node_modules|bower_components|public|__[a-z0-9_]+__|coverage)[\\\/]/,
/\.test\.js$/,
...extraIgnores,
+ 'plugins/java_languageserver'
],
});
diff --git a/src/cli_plugin/install/__fixtures__/replies/test_plugin.zip b/src/cli_plugin/install/__fixtures__/replies/test_plugin.zip
index 1ec957f80128b..544abf86007e6 100644
Binary files a/src/cli_plugin/install/__fixtures__/replies/test_plugin.zip and b/src/cli_plugin/install/__fixtures__/replies/test_plugin.zip differ
diff --git a/src/cli_plugin/install/zip.js b/src/cli_plugin/install/zip.js
index 25cdac42398db..7f928275525d5 100644
--- a/src/cli_plugin/install/zip.js
+++ b/src/cli_plugin/install/zip.js
@@ -132,7 +132,7 @@ export function extractArchive(archive, targetDir, extractPath) {
return reject(err);
}
- readStream.pipe(createWriteStream(fileName));
+ readStream.pipe(createWriteStream(fileName, { mode: entry.externalFileAttributes >>> 16 }));
readStream.on('end', function () {
zipfile.readEntry();
});
diff --git a/src/cli_plugin/install/zip.test.js b/src/cli_plugin/install/zip.test.js
index 516e0abe25743..340dec196eef5 100644
--- a/src/cli_plugin/install/zip.test.js
+++ b/src/cli_plugin/install/zip.test.js
@@ -21,6 +21,7 @@ import rimraf from 'rimraf';
import path from 'path';
import os from 'os';
import glob from 'glob';
+import fs from 'fs';
import { analyzeArchive, extractArchive, _isDirectory } from './zip';
describe('kibana cli', function () {
@@ -72,6 +73,28 @@ describe('kibana cli', function () {
});
});
+ describe('checkFilePermission', () => {
+ it('verify consistency of modes of files', async () => {
+ const archivePath = path.resolve(repliesPath, 'test_plugin.zip');
+
+ await extractArchive(archivePath, tempPath, 'kibana/libs');
+ const files = await glob.sync('**/*', { cwd: tempPath });
+
+ const expected = [
+ 'executable',
+ 'unexecutable'
+ ];
+ expect(files.sort()).toEqual(expected.sort());
+
+ const executableMode = '0' + (fs.statSync(path.resolve(tempPath, 'executable')).mode & parseInt('777', 8)).toString(8);
+ const unExecutableMode = '0' + (fs.statSync(path.resolve(tempPath, 'unexecutable')).mode & parseInt('777', 8)).toString(8);
+
+ expect(executableMode).toEqual('0755');
+ expect(unExecutableMode).toEqual('0644');
+
+ });
+ });
+
it('handles a corrupt zip archive', async () => {
try {
await extractArchive(path.resolve(repliesPath, 'corrupt.zip'));
diff --git a/src/dev/build/build_distributables.js b/src/dev/build/build_distributables.js
index bb450dcd006bc..acbbddcce8e72 100644
--- a/src/dev/build/build_distributables.js
+++ b/src/dev/build/build_distributables.js
@@ -44,6 +44,7 @@ import {
ExtractNodeBuildsTask,
InstallDependenciesTask,
OptimizeBuildTask,
+ PatchNativeModulesTask,
RemovePackageJsonDepsTask,
RemoveWorkspacesTask,
TranspileBabelTask,
@@ -131,6 +132,7 @@ export async function buildDistributables(options) {
* directories and perform platform-specific steps
*/
await run(CreateArchivesSourcesTask);
+ await run(PatchNativeModulesTask);
await run(CleanExtraBinScriptsTask);
await run(CleanExtraBrowsersTask);
await run(CleanNodeBuildsTask);
diff --git a/src/dev/build/tasks/index.js b/src/dev/build/tasks/index.js
index acf4680fd6f42..c471a7aafe118 100644
--- a/src/dev/build/tasks/index.js
+++ b/src/dev/build/tasks/index.js
@@ -37,4 +37,5 @@ export * from './typecheck_typescript_task';
export * from './transpile_scss_task';
export * from './verify_env_task';
export * from './write_sha_sums_task';
+export * from './patch_native_modules_task';
export * from './path_length_task';
diff --git a/src/dev/build/tasks/patch_native_modules_task.js b/src/dev/build/tasks/patch_native_modules_task.js
new file mode 100644
index 0000000000000..0d55f225fd74e
--- /dev/null
+++ b/src/dev/build/tasks/patch_native_modules_task.js
@@ -0,0 +1,91 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { scanCopy, untar, deleteAll } from '../lib';
+import { createWriteStream } from 'fs';
+import { binaryInfo } from '../../../../x-pack/plugins/code/tasks/nodegit_info';
+import wreck from 'wreck';
+import mkdirp from 'mkdirp';
+import { dirname, join, basename } from 'path';
+import { createPromiseFromStreams } from '../../../legacy/utils/streams';
+
+async function download(url, destination, log) {
+ const response = await wreck.request('GET', url);
+
+ if (response.statusCode !== 200) {
+ throw new Error(
+ `Unexpected status code ${response.statusCode} when downloading ${url}`
+ );
+ }
+ mkdirp.sync(dirname(destination));
+ await createPromiseFromStreams([
+ response,
+ createWriteStream(destination)
+ ]);
+ log.debug('Downloaded ', url);
+}
+
+async function downloadAndExtractTarball(url, dest, log, retry) {
+ try {
+ await download(url, dest, log);
+ const extractDir = join(dirname(dest), basename(dest, '.tar.gz'));
+ await untar(dest, extractDir, {
+ strip: 1
+ });
+ return extractDir;
+ } catch (e) {
+ if (retry > 0) {
+ await downloadAndExtractTarball(url, dest, log, retry - 1);
+ } else {
+ throw e;
+ }
+ }
+}
+
+async function patchNodeGit(config, log, build, platform) {
+ const plat = platform.isWindows() ? 'win32' : platform.getName();
+ const arch = platform.getNodeArch().split('-')[1];
+ const { downloadUrl, packageName } = binaryInfo(plat, arch);
+
+ const downloadPath = build.resolvePathForPlatform(platform, '.nodegit_binaries', packageName);
+ const extractDir = await downloadAndExtractTarball(downloadUrl, downloadPath, log, 3);
+
+ const destination = build.resolvePathForPlatform(platform, 'node_modules/nodegit/build/Release');
+ log.debug('Replacing nodegit binaries from ', extractDir);
+ await deleteAll([destination], log);
+ await scanCopy({
+ source: extractDir,
+ destination: destination,
+ time: new Date(),
+ });
+ await deleteAll([extractDir, downloadPath], log);
+}
+
+
+
+export const PatchNativeModulesTask = {
+ description: 'Patching platform-specific native modules directories',
+ async run(config, log, build) {
+ await Promise.all(config.getTargetPlatforms().map(async platform => {
+ if (!build.isOss()) {
+ await patchNodeGit(config, log, build, platform);
+ }
+ }));
+ }
+};
diff --git a/src/legacy/ui/public/chrome/api/breadcrumbs.ts b/src/legacy/ui/public/chrome/api/breadcrumbs.ts
index 9c58f265f4131..d65897d60d8f4 100644
--- a/src/legacy/ui/public/chrome/api/breadcrumbs.ts
+++ b/src/legacy/ui/public/chrome/api/breadcrumbs.ts
@@ -72,6 +72,13 @@ function createBreadcrumbsApi(chrome: { [key: string]: any }) {
filter(fn: (breadcrumb: Breadcrumb, i: number, all: Breadcrumb[]) => boolean) {
newPlatformChrome.setBreadcrumbs(currentBreadcrumbs.filter(fn));
},
+
+ /**
+ * Remove last element of the breadcrumb
+ */
+ pop() {
+ newPlatformChrome.setBreadcrumbs(currentBreadcrumbs.slice(0, -1));
+ },
},
};
}
diff --git a/x-pack/index.js b/x-pack/index.js
index 3c9f683ed0796..86bed4bcc8851 100644
--- a/x-pack/index.js
+++ b/x-pack/index.js
@@ -18,6 +18,7 @@ import { dashboardMode } from './plugins/dashboard_mode';
import { logstash } from './plugins/logstash';
import { beats } from './plugins/beats_management';
import { apm } from './plugins/apm';
+import { code } from './plugins/code';
import { maps } from './plugins/maps';
import { licenseManagement } from './plugins/license_management';
import { cloud } from './plugins/cloud';
@@ -55,6 +56,7 @@ module.exports = function (kibana) {
logstash(kibana),
beats(kibana),
apm(kibana),
+ code(kibana),
maps(kibana),
canvas(kibana),
licenseManagement(kibana),
diff --git a/x-pack/package.json b/x-pack/package.json
index b2872af155d34..c4245edadf22e 100644
--- a/x-pack/package.json
+++ b/x-pack/package.json
@@ -42,6 +42,7 @@
"@storybook/react": "^5.0.5",
"@storybook/theming": "^5.0.5",
"@types/angular": "1.6.50",
+ "@types/boom": "^7.2.0",
"@types/base64-js": "^1.2.5",
"@types/cheerio": "^0.22.10",
"@types/chroma-js": "^1.4.1",
@@ -52,32 +53,46 @@
"@types/d3-time": "^1.0.7",
"@types/d3-time-format": "^2.1.0",
"@types/elasticsearch": "^5.0.30",
+ "@types/git-url-parse": "^9.0.0",
+ "@types/glob": "^7.1.1",
"@types/file-saver": "^2.0.0",
"@types/graphql": "^0.13.1",
"@types/hapi-auth-cookie": "^9.1.0",
"@types/history": "^4.6.2",
"@types/jest": "^24.0.9",
"@types/joi": "^13.4.2",
+ "@types/js-yaml": "^3.11.1",
"@types/json-stable-stringify": "^1.0.32",
"@types/jsonwebtoken": "^7.2.7",
"@types/lodash": "^3.10.1",
+ "@types/mkdirp": "^0.5.2",
"@types/mime": "^2.0.1",
"@types/mocha": "^5.2.6",
+ "@types/nock": "^9.3.0",
+ "@types/node": "^10.12.27",
+ "@types/node-fetch": "^2.1.4",
"@types/object-hash": "^1.2.0",
+ "@types/papaparse": "^4.5.5",
"@types/pngjs": "^3.3.1",
"@types/prop-types": "^15.5.3",
+ "@types/proper-lockfile": "^3.0.0",
"@types/react": "^16.8.0",
"@types/react-dom": "^16.8.0",
"@types/react-redux": "^6.0.6",
"@types/react-router-dom": "^4.3.1",
+ "@types/react-test-renderer": "^16.8.0",
"@types/recompose": "^0.30.2",
"@types/reduce-reducers": "^0.1.3",
- "@types/sinon": "^5.0.1",
+ "@types/redux-actions": "^2.2.1",
+ "@types/rimraf": "^2.0.2",
+ "@types/sinon": "^7.0.0",
"@types/storybook__addon-actions": "^3.4.2",
"@types/storybook__addon-info": "^4.1.1",
"@types/storybook__addon-knobs": "^4.0.4",
"@types/storybook__react": "^4.0.1",
+ "@types/styled-components": "^3.0.1",
"@types/supertest": "^2.0.5",
+ "@types/tar-fs": "^1.16.1",
"@types/tinycolor2": "^1.4.1",
"@types/uuid": "^3.4.4",
"abab": "^1.0.4",
@@ -133,7 +148,7 @@
"run-sequence": "^2.2.1",
"sass-loader": "^7.1.0",
"simple-git": "1.37.0",
- "sinon": "^5.0.7",
+ "sinon": "^7.2.2",
"string-replace-loader": "^2.1.1",
"supertest": "^3.1.0",
"supertest-as-promised": "^4.0.2",
@@ -151,7 +166,10 @@
"@babel/runtime": "^7.3.4",
"@elastic/datemath": "5.0.2",
"@elastic/eui": "10.1.0",
+ "@elastic/javascript-typescript-langserver": "^0.1.23",
+ "@elastic/lsp-extension": "^0.1.1",
"@elastic/node-crypto": "0.1.2",
+ "@elastic/nodegit": "0.25.0-alpha.12",
"@elastic/numeral": "2.3.3",
"@kbn/babel-preset": "1.0.0",
"@kbn/elastic-idx": "1.0.0",
@@ -195,26 +213,33 @@
"elasticsearch": "^15.4.1",
"extract-zip": "1.5.0",
"file-saver": "^1.3.8",
+ "file-type": "^10.9.0",
"font-awesome": "4.4.0",
"formsy-react": "^1.1.5",
- "get-port": "2.1.0",
+ "get-port": "4.2.0",
"getos": "^3.1.0",
- "glob": "6.0.4",
+ "git-url-parse": "11.1.2",
+ "github-markdown-css": "^2.10.0",
+ "glob": "^7.1.2",
"graphql": "^0.13.2",
"graphql-fields": "^1.0.2",
"graphql-tag": "^2.9.2",
"graphql-tools": "^3.0.2",
+ "h2o2": "^8.1.2",
"handlebars": "^4.0.13",
"hapi-auth-cookie": "^9.0.0",
"history": "4.7.2",
"history-extra": "^4.0.2",
"humps": "2.0.1",
"icalendar": "0.7.1",
+ "idx": "^2.5.2",
+ "immer": "^1.5.0",
"inline-style": "^2.0.0",
"intl": "^1.2.5",
"io-ts": "^1.4.2",
"joi": "^13.5.2",
"jquery": "^3.4.0",
+ "js-yaml": "3.4.1",
"json-stable-stringify": "^1.0.1",
"jsonwebtoken": "^8.3.0",
"lodash": "npm:@elastic/lodash@3.10.1-kibana1",
@@ -234,7 +259,9 @@
"moment": "^2.20.1",
"moment-duration-format": "^1.3.0",
"moment-timezone": "^0.5.14",
+ "monaco-editor": "^0.14.3",
"ngreact": "^0.5.1",
+ "nock": "10.0.4",
"node-fetch": "^2.1.2",
"nodemailer": "^4.6.4",
"object-hash": "^1.3.1",
@@ -245,7 +272,9 @@
"pluralize": "3.1.0",
"pngjs": "3.3.1",
"polished": "^1.9.2",
+ "popper.js": "^1.14.3",
"prop-types": "^15.6.0",
+ "proper-lockfile": "^3.0.2",
"puid": "1.0.5",
"puppeteer-core": "^1.13.0",
"raw-loader": "0.5.1",
@@ -257,6 +286,7 @@
"react-dom": "^16.8.0",
"react-dropzone": "^4.2.9",
"react-fast-compare": "^2.0.4",
+ "react-markdown": "^3.4.1",
"react-markdown-renderer": "^1.4.0",
"react-portal": "^3.2.0",
"react-redux": "^5.0.7",
@@ -272,6 +302,7 @@
"redux": "4.0.0",
"redux-actions": "2.2.1",
"redux-observable": "^1.0.0",
+ "redux-saga": "^0.16.0",
"redux-thunk": "2.3.0",
"redux-thunks": "^1.0.0",
"request": "^2.88.0",
@@ -282,6 +313,7 @@
"rxjs": "^6.2.1",
"semver": "5.1.0",
"squel": "^5.12.2",
+ "stats-lite": "^2.2.0",
"style-it": "2.1.2",
"styled-components": "3.3.3",
"tar-fs": "1.13.0",
@@ -297,6 +329,9 @@
"unstated": "^2.1.1",
"uuid": "3.0.1",
"venn.js": "0.2.9",
+ "vscode-jsonrpc": "^3.6.2",
+ "vscode-languageserver": "^4.2.1",
+ "vscode-languageserver-types": "^3.10.0",
"xml2js": "^0.4.19",
"xregexp": "3.2.0"
},
diff --git a/x-pack/plugins/code/common/git_blame.ts b/x-pack/plugins/code/common/git_blame.ts
new file mode 100644
index 0000000000000..17f0622c69a30
--- /dev/null
+++ b/x-pack/plugins/code/common/git_blame.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface GitBlame {
+ committer: {
+ name: string;
+ email: string;
+ };
+ startLine: number;
+ lines: number;
+ commit: {
+ id: string;
+ message: string;
+ date: string;
+ };
+}
diff --git a/x-pack/plugins/code/common/git_diff.ts b/x-pack/plugins/code/common/git_diff.ts
new file mode 100644
index 0000000000000..89f1bd1cf2fad
--- /dev/null
+++ b/x-pack/plugins/code/common/git_diff.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CommitInfo } from '../model/commit';
+
+export interface Diff {
+ additions: number;
+ deletions: number;
+ files: FileDiff[];
+}
+
+export interface CommitDiff extends Diff {
+ commit: CommitInfo;
+}
+
+export interface FileDiff {
+ path: string;
+ originPath?: string;
+ kind: DiffKind;
+ originCode?: string;
+ modifiedCode?: string;
+ language?: string;
+ additions: number;
+ deletions: number;
+}
+
+export enum DiffKind {
+ ADDED = 'ADDED',
+ DELETED = 'DELETED',
+ MODIFIED = 'MODIFIED',
+ RENAMED = 'RENAMED',
+}
diff --git a/x-pack/plugins/code/common/git_url_utils.test.ts b/x-pack/plugins/code/common/git_url_utils.test.ts
new file mode 100644
index 0000000000000..f3c53eb956468
--- /dev/null
+++ b/x-pack/plugins/code/common/git_url_utils.test.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { validateGitUrl } from './git_url_utils';
+
+test('Git url validation', () => {
+ // An url ends with .git
+ expect(validateGitUrl('https://github.com/elastic/elasticsearch.git')).toBeTruthy();
+
+ // An url ends without .git
+ expect(validateGitUrl('https://github.com/elastic/elasticsearch')).toBeTruthy();
+
+ // An url with http://
+ expect(validateGitUrl('http://github.com/elastic/elasticsearch')).toBeTruthy();
+
+ // An url with ssh://
+ expect(validateGitUrl('ssh://elastic@github.com/elastic/elasticsearch.git')).toBeTruthy();
+
+ // An url with ssh:// and port
+ expect(validateGitUrl('ssh://elastic@github.com:9999/elastic/elasticsearch.git')).toBeTruthy();
+
+ // An url with git://
+ expect(validateGitUrl('git://elastic@github.com/elastic/elasticsearch.git')).toBeTruthy();
+
+ // An url with an invalid protocol
+ expect(() => {
+ validateGitUrl('file:///Users/elastic/elasticsearch', [], ['ssh', 'https', 'git']);
+ }).toThrow('Git url protocol is not whitelisted.');
+
+ // An url without protocol
+ expect(() => {
+ validateGitUrl('/Users/elastic/elasticsearch', [], ['ssh', 'https', 'git']);
+ }).toThrow('Git url protocol is not whitelisted.');
+ expect(() => {
+ validateGitUrl('github.com/elastic/elasticsearch', [], ['ssh', 'https', 'git']);
+ }).toThrow('Git url protocol is not whitelisted.');
+
+ // An valid git url but without whitelisted host
+ expect(() => {
+ validateGitUrl('https://github.com/elastic/elasticsearch.git', ['gitlab.com']);
+ }).toThrow('Git url host is not whitelisted.');
+
+ // An valid git url but without whitelisted protocol
+ expect(() => {
+ validateGitUrl('https://github.com/elastic/elasticsearch.git', [], ['ssh']);
+ }).toThrow('Git url protocol is not whitelisted.');
+
+ // An valid git url with both whitelisted host and protocol
+ expect(
+ validateGitUrl('https://github.com/elastic/elasticsearch.git', ['github.com'], ['https'])
+ ).toBeTruthy();
+});
diff --git a/x-pack/plugins/code/common/git_url_utils.ts b/x-pack/plugins/code/common/git_url_utils.ts
new file mode 100644
index 0000000000000..27159cf91cc42
--- /dev/null
+++ b/x-pack/plugins/code/common/git_url_utils.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import GitUrlParse from 'git-url-parse';
+
+// return true if the git url is valid, otherwise throw Error with
+// exact reasons.
+export function validateGitUrl(
+ url: string,
+ hostWhitelist?: string[],
+ protocolWhitelist?: string[]
+): boolean {
+ const repo = GitUrlParse(url);
+
+ if (hostWhitelist && hostWhitelist.length > 0) {
+ const hostSet = new Set(hostWhitelist);
+ if (!hostSet.has(repo.source)) {
+ throw new Error('Git url host is not whitelisted.');
+ }
+ }
+
+ if (protocolWhitelist && protocolWhitelist.length > 0) {
+ const protocolSet = new Set(protocolWhitelist);
+ if (!protocolSet.has(repo.protocol)) {
+ throw new Error('Git url protocol is not whitelisted.');
+ }
+ }
+ return true;
+}
diff --git a/x-pack/plugins/code/common/installation.ts b/x-pack/plugins/code/common/installation.ts
new file mode 100644
index 0000000000000..5a39ab227ac02
--- /dev/null
+++ b/x-pack/plugins/code/common/installation.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export enum InstallationType {
+ Embed,
+ Download,
+ Plugin,
+}
+
+export enum InstallEventType {
+ DOWNLOADING,
+ UNPACKING,
+ DONE,
+ FAIL,
+}
+
+export interface InstallEvent {
+ langServerName: string;
+ eventType: InstallEventType;
+ progress?: number;
+ message?: string;
+ params?: any;
+}
diff --git a/x-pack/plugins/code/common/language_server.ts b/x-pack/plugins/code/common/language_server.ts
new file mode 100644
index 0000000000000..74899a17a658a
--- /dev/null
+++ b/x-pack/plugins/code/common/language_server.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { InstallationType } from './installation';
+
+export enum LanguageServerStatus {
+ NOT_INSTALLED,
+ INSTALLING,
+ READY, // installed but not running
+ RUNNING,
+}
+
+export interface LanguageServer {
+ name: string;
+ languages: string[];
+ installationType: InstallationType;
+ version?: string;
+ build?: string;
+ status?: LanguageServerStatus;
+ downloadUrl?: any;
+ pluginName?: string;
+}
diff --git a/x-pack/plugins/code/common/line_mapper.ts b/x-pack/plugins/code/common/line_mapper.ts
new file mode 100644
index 0000000000000..c11b22acba3bd
--- /dev/null
+++ b/x-pack/plugins/code/common/line_mapper.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import _ from 'lodash';
+
+import { SourceLocation } from '../model';
+
+export class LineMapper {
+ private lines: string[];
+ private acc: number[];
+
+ constructor(content: string) {
+ this.lines = content.split('\n');
+ this.acc = [0];
+ this.getLocation = this.getLocation.bind(this);
+
+ for (let i = 0; i < this.lines.length - 1; i++) {
+ this.acc[i + 1] = this.acc[i] + this.lines[i].length + 1;
+ }
+ }
+
+ public getLocation(offset: number): SourceLocation {
+ let line = _.sortedIndex(this.acc, offset);
+ if (offset !== this.acc[line]) {
+ line -= 1;
+ }
+ const column = offset - this.acc[line];
+ return { line, column, offset };
+ }
+
+ public getLines(): string[] {
+ return this.lines;
+ }
+}
diff --git a/x-pack/plugins/code/common/lsp_client.ts b/x-pack/plugins/code/common/lsp_client.ts
new file mode 100644
index 0000000000000..a40ed0ebe5770
--- /dev/null
+++ b/x-pack/plugins/code/common/lsp_client.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ResponseError, ResponseMessage } from 'vscode-jsonrpc/lib/messages';
+
+export { TextDocumentMethods } from './text_document_methods';
+import { kfetch } from 'ui/kfetch';
+
+export interface LspClient {
+ sendRequest(method: string, params: any, singal?: AbortSignal): Promise;
+}
+
+export class LspRestClient implements LspClient {
+ private baseUri: string;
+
+ constructor(baseUri: string) {
+ this.baseUri = baseUri;
+ }
+
+ public async sendRequest(
+ method: string,
+ params: any,
+ signal?: AbortSignal
+ ): Promise {
+ try {
+ const response = await kfetch({
+ pathname: `${this.baseUri}/${method}`,
+ method: 'POST',
+ body: JSON.stringify(params),
+ signal,
+ });
+ return response as ResponseMessage;
+ } catch (e) {
+ let error = e;
+ if (error.body && error.body.error) {
+ error = error.body.error;
+ }
+ throw new ResponseError(error.code, error.message, error.data);
+ }
+ }
+}
diff --git a/x-pack/plugins/code/common/lsp_error_codes.ts b/x-pack/plugins/code/common/lsp_error_codes.ts
new file mode 100644
index 0000000000000..c39384cefc7a2
--- /dev/null
+++ b/x-pack/plugins/code/common/lsp_error_codes.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ErrorCodes } from 'vscode-jsonrpc/lib/messages';
+
+export const ServerNotInitialized: number = ErrorCodes.ServerNotInitialized;
+export const UnknownErrorCode: number = ErrorCodes.UnknownErrorCode;
+export const UnknownFileLanguage: number = -42404;
+export const LanguageServerNotInstalled: number = -42403;
+export const LanguageDisabled: number = -42402;
diff --git a/x-pack/plugins/code/common/lsp_method.ts b/x-pack/plugins/code/common/lsp_method.ts
new file mode 100644
index 0000000000000..82353b671080f
--- /dev/null
+++ b/x-pack/plugins/code/common/lsp_method.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AsyncTask } from '../public/monaco/computer';
+import { LspClient } from './lsp_client';
+
+export class LspMethod {
+ private client: LspClient;
+ private method: string;
+
+ constructor(method: string, client: LspClient) {
+ this.client = client;
+ this.method = method;
+ }
+
+ public asyncTask(input: INPUT): AsyncTask {
+ const abortController = new AbortController();
+ const promise = () => {
+ return this.client
+ .sendRequest(this.method, input, abortController.signal)
+ .then(result => result.result as OUTPUT);
+ };
+ return {
+ cancel() {
+ abortController.abort();
+ },
+ promise,
+ };
+ }
+
+ public async send(input: INPUT): Promise {
+ return await this.client
+ .sendRequest(this.method, input)
+ .then(result => result.result as OUTPUT);
+ }
+}
diff --git a/x-pack/plugins/code/common/repository_utils.test.ts b/x-pack/plugins/code/common/repository_utils.test.ts
new file mode 100644
index 0000000000000..bdaf1aba3335a
--- /dev/null
+++ b/x-pack/plugins/code/common/repository_utils.test.ts
@@ -0,0 +1,206 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FileTreeItemType } from '../model';
+import { RepositoryUtils } from './repository_utils';
+
+test('Repository url parsing', () => {
+ // Valid git url without .git suffix.
+ const repo1 = RepositoryUtils.buildRepository('https://github.com/apache/sqoop');
+ expect(repo1).toEqual({
+ uri: 'github.com/apache/sqoop',
+ url: 'https://github.com/apache/sqoop',
+ name: 'sqoop',
+ org: 'apache',
+ protocol: 'https',
+ });
+
+ // Valid git url with .git suffix.
+ const repo2 = RepositoryUtils.buildRepository('https://github.com/apache/sqoop.git');
+ expect(repo2).toEqual({
+ uri: 'github.com/apache/sqoop',
+ url: 'https://github.com/apache/sqoop.git',
+ name: 'sqoop',
+ protocol: 'https',
+ org: 'apache',
+ });
+
+ // An invalid git url
+ const repo3 = RepositoryUtils.buildRepository('github.com/apache/sqoop');
+ expect(repo3).toMatchObject({
+ uri: 'github.com/apache/sqoop',
+ url: 'github.com/apache/sqoop',
+ });
+
+ const repo4 = RepositoryUtils.buildRepository('git://a/b');
+ expect(repo4).toEqual({
+ uri: 'a/_/b',
+ url: 'git://a/b',
+ name: 'b',
+ org: '_',
+ protocol: 'git',
+ });
+
+ const repo5 = RepositoryUtils.buildRepository('git://a/b/c');
+ expect(repo5).toEqual({
+ uri: 'a/b/c',
+ url: 'git://a/b/c',
+ name: 'c',
+ org: 'b',
+ protocol: 'git',
+ });
+
+ const repo6 = RepositoryUtils.buildRepository('git@github.com:foo/bar.git');
+ expect(repo6).toEqual({
+ uri: 'github.com/foo/bar',
+ url: 'git@github.com:foo/bar.git',
+ name: 'bar',
+ protocol: 'ssh',
+ org: 'foo',
+ });
+
+ const repo7 = RepositoryUtils.buildRepository('ssh://git@github.com:foo/bar.git');
+ expect(repo7).toEqual({
+ uri: 'github.com/foo/bar',
+ url: 'ssh://git@github.com:foo/bar.git',
+ name: 'bar',
+ org: 'foo',
+ protocol: 'ssh',
+ });
+});
+
+test('Repository url parsing with non standard segments', () => {
+ const repo1 = RepositoryUtils.buildRepository('git://a/b/c/d');
+ expect(repo1).toEqual({
+ uri: 'a/b_c/d',
+ url: 'git://a/b/c/d',
+ name: 'd',
+ org: 'b_c',
+ protocol: 'git',
+ });
+
+ const repo2 = RepositoryUtils.buildRepository('git://a/b/c/d/e');
+ expect(repo2).toEqual({
+ uri: 'a/b_c_d/e',
+ url: 'git://a/b/c/d/e',
+ name: 'e',
+ org: 'b_c_d',
+ protocol: 'git',
+ });
+
+ const repo3 = RepositoryUtils.buildRepository('git://a');
+ expect(repo3).toEqual({
+ uri: 'a/_/_',
+ url: 'git://a',
+ name: '_',
+ protocol: 'git',
+ org: '_',
+ });
+});
+
+test('Repository url parsing with port', () => {
+ const repo1 = RepositoryUtils.buildRepository('ssh://mine@mydomain.com:27017/gitolite-admin');
+ expect(repo1).toEqual({
+ uri: 'mydomain.com:27017/mine/gitolite-admin',
+ url: 'ssh://mine@mydomain.com:27017/gitolite-admin',
+ name: 'gitolite-admin',
+ org: 'mine',
+ protocol: 'ssh',
+ });
+
+ const repo2 = RepositoryUtils.buildRepository(
+ 'ssh://mine@mydomain.com:27017/elastic/gitolite-admin'
+ );
+ expect(repo2).toEqual({
+ uri: 'mydomain.com:27017/elastic/gitolite-admin',
+ url: 'ssh://mine@mydomain.com:27017/elastic/gitolite-admin',
+ name: 'gitolite-admin',
+ protocol: 'ssh',
+ org: 'elastic',
+ });
+});
+
+test('Normalize repository index name', () => {
+ const indexName1 = RepositoryUtils.normalizeRepoUriToIndexName('github.com/elastic/Kibana');
+ const indexName2 = RepositoryUtils.normalizeRepoUriToIndexName('github.com/elastic/kibana');
+
+ expect(indexName1 === indexName2).toBeFalsy();
+ expect(indexName1).toEqual('github.aaakk.us.kg-elastic-kibana-e2b881a9');
+ expect(indexName2).toEqual('github.aaakk.us.kg-elastic-kibana-7bf00473');
+
+ const indexName3 = RepositoryUtils.normalizeRepoUriToIndexName('github.com/elastic-kibana/code');
+ const indexName4 = RepositoryUtils.normalizeRepoUriToIndexName('github.com/elastic/kibana-code');
+ expect(indexName3 === indexName4).toBeFalsy();
+});
+
+test('Parse repository uri', () => {
+ expect(RepositoryUtils.orgNameFromUri('github.com/elastic/kibana')).toEqual('elastic');
+ expect(RepositoryUtils.repoNameFromUri('github.com/elastic/kibana')).toEqual('kibana');
+ expect(RepositoryUtils.repoFullNameFromUri('github.com/elastic/kibana')).toEqual(
+ 'elastic/kibana'
+ );
+
+ // For invalid repository uri
+ expect(() => {
+ RepositoryUtils.orgNameFromUri('foo/bar');
+ }).toThrowError('Invalid repository uri.');
+ expect(() => {
+ RepositoryUtils.repoNameFromUri('foo/bar');
+ }).toThrowError('Invalid repository uri.');
+ expect(() => {
+ RepositoryUtils.repoFullNameFromUri('foo/bar');
+ }).toThrowError('Invalid repository uri.');
+});
+
+test('Repository local path', () => {
+ expect(RepositoryUtils.repositoryLocalPath('/tmp', 'github.com/elastic/kibana')).toEqual(
+ '/tmp/github.com/elastic/kibana'
+ );
+ expect(RepositoryUtils.repositoryLocalPath('tmp', 'github.com/elastic/kibana')).toEqual(
+ 'tmp/github.com/elastic/kibana'
+ );
+});
+
+test('Parse location to url', () => {
+ expect(
+ RepositoryUtils.locationToUrl({
+ uri: 'git://github.com/elastic/eui/blob/master/generator-eui/app/component.js',
+ range: {
+ start: {
+ line: 4,
+ character: 17,
+ },
+ end: {
+ line: 27,
+ character: 1,
+ },
+ },
+ })
+ ).toEqual('/github.com/elastic/eui/blob/master/generator-eui/app/component.js!L5:17');
+});
+
+test('Get all files from a repository file tree', () => {
+ expect(
+ RepositoryUtils.getAllFiles({
+ name: 'foo',
+ type: FileTreeItemType.Directory,
+ path: '/foo',
+ children: [
+ {
+ name: 'bar',
+ type: FileTreeItemType.File,
+ path: '/foo/bar',
+ },
+ {
+ name: 'boo',
+ type: FileTreeItemType.File,
+ path: '/foo/boo',
+ },
+ ],
+ childrenCount: 2,
+ })
+ ).toEqual(['/foo/bar', '/foo/boo']);
+});
diff --git a/x-pack/plugins/code/common/repository_utils.ts b/x-pack/plugins/code/common/repository_utils.ts
new file mode 100644
index 0000000000000..37813be8f80e6
--- /dev/null
+++ b/x-pack/plugins/code/common/repository_utils.ts
@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import crypto from 'crypto';
+import GitUrlParse from 'git-url-parse';
+import path from 'path';
+import { Location } from 'vscode-languageserver';
+
+import { CloneProgress, FileTree, FileTreeItemType, Repository, RepositoryUri } from '../model';
+import { parseLspUrl, toCanonicalUrl } from './uri_util';
+
+export class RepositoryUtils {
+ // Generate a Repository instance by parsing repository remote url
+ public static buildRepository(remoteUrl: string): Repository {
+ const repo = GitUrlParse(remoteUrl);
+ let host = repo.source ? repo.source : '';
+ if (repo.port !== null) {
+ host = host + ':' + repo.port;
+ }
+ const name = repo.name ? repo.name : '_';
+ const org = repo.owner ? repo.owner.split('/').join('_') : '_';
+ const uri: RepositoryUri = host ? `${host}/${org}/${name}` : repo.full_name;
+ return {
+ uri,
+ url: repo.href as string,
+ name,
+ org,
+ protocol: repo.protocol,
+ };
+ }
+
+ // From uri 'origin/org/name' to 'org'
+ public static orgNameFromUri(repoUri: RepositoryUri): string {
+ const segs = repoUri.split('/');
+ if (segs && segs.length === 3) {
+ return segs[1];
+ }
+
+ throw new Error('Invalid repository uri.');
+ }
+
+ // From uri 'origin/org/name' to 'name'
+ public static repoNameFromUri(repoUri: RepositoryUri): string {
+ const segs = repoUri.split('/');
+ if (segs && segs.length === 3) {
+ return segs[2];
+ }
+
+ throw new Error('Invalid repository uri.');
+ }
+
+ // From uri 'origin/org/name' to 'org/name'
+ public static repoFullNameFromUri(repoUri: RepositoryUri): string {
+ const segs = repoUri.split('/');
+ if (segs && segs.length === 3) {
+ return segs[1] + '/' + segs[2];
+ }
+
+ throw new Error('Invalid repository uri.');
+ }
+
+ // Return the local data path of a given repository.
+ public static repositoryLocalPath(repoPath: string, repoUri: RepositoryUri) {
+ return path.join(repoPath, repoUri);
+ }
+
+ public static normalizeRepoUriToIndexName(repoUri: RepositoryUri) {
+ const hash = crypto
+ .createHash('md5')
+ .update(repoUri)
+ .digest('hex')
+ .substring(0, 8);
+ const segs: string[] = repoUri.split('/');
+ segs.push(hash);
+ // Elasticsearch index name is case insensitive
+ return segs.join('-').toLowerCase();
+ }
+
+ public static locationToUrl(loc: Location) {
+ const url = parseLspUrl(loc.uri);
+ const { repoUri, file, revision } = url;
+ if (repoUri && file && revision) {
+ return toCanonicalUrl({ repoUri, file, revision, position: loc.range.start });
+ }
+ return '';
+ }
+
+ public static getAllFiles(fileTree: FileTree): string[] {
+ if (!fileTree) {
+ return [];
+ }
+ let result: string[] = [];
+ switch (fileTree.type) {
+ case FileTreeItemType.File:
+ result.push(fileTree.path!);
+ break;
+ case FileTreeItemType.Directory:
+ for (const node of fileTree.children!) {
+ result = result.concat(RepositoryUtils.getAllFiles(node));
+ }
+ break;
+ default:
+ break;
+ }
+ return result;
+ }
+
+ public static hasFullyCloned(cloneProgress?: CloneProgress | null): boolean {
+ return !!cloneProgress && cloneProgress.isCloned !== undefined && cloneProgress.isCloned;
+ }
+}
diff --git a/x-pack/plugins/code/common/text_document_methods.ts b/x-pack/plugins/code/common/text_document_methods.ts
new file mode 100644
index 0000000000000..bc9bfcf477be4
--- /dev/null
+++ b/x-pack/plugins/code/common/text_document_methods.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SymbolLocator } from '@elastic/lsp-extension';
+import { TextDocumentPositionParams } from 'vscode-languageserver';
+import {
+ Definition,
+ DocumentSymbolParams,
+ Hover,
+ Location,
+ SymbolInformation,
+} from 'vscode-languageserver-types';
+import { LspClient } from './lsp_client';
+import { LspMethod } from './lsp_method';
+
+export class TextDocumentMethods {
+ public documentSymbol: LspMethod;
+ public hover: LspMethod;
+ public definition: LspMethod;
+ public edefinition: LspMethod;
+ public references: LspMethod;
+
+ private readonly client: LspClient;
+
+ constructor(client: LspClient) {
+ this.client = client;
+ this.documentSymbol = new LspMethod('textDocument/documentSymbol', this.client);
+ this.hover = new LspMethod('textDocument/hover', this.client);
+ this.definition = new LspMethod('textDocument/definition', this.client);
+ this.edefinition = new LspMethod('textDocument/edefinition', this.client);
+ this.references = new LspMethod('textDocument/references', this.client);
+ }
+}
diff --git a/x-pack/plugins/code/common/uri_util.test.ts b/x-pack/plugins/code/common/uri_util.test.ts
new file mode 100644
index 0000000000000..fbdc739b8f944
--- /dev/null
+++ b/x-pack/plugins/code/common/uri_util.test.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RepositoryUri } from '../model';
+import { parseLspUrl, toCanonicalUrl, toRepoName, toRepoNameWithOrg } from './uri_util';
+
+test('parse a complete uri', () => {
+ const fullUrl =
+ 'git://github.com/Microsoft/vscode/blob/f2e49a2/src/vs/base/parts/ipc/test/node/ipc.net.test.ts';
+ const result = parseLspUrl(fullUrl);
+ expect(result).toEqual({
+ uri:
+ '/github.com/Microsoft/vscode/blob/f2e49a2/src/vs/base/parts/ipc/test/node/ipc.net.test.ts',
+ repoUri: 'github.com/Microsoft/vscode',
+ pathType: 'blob',
+ revision: 'f2e49a2',
+ file: 'src/vs/base/parts/ipc/test/node/ipc.net.test.ts',
+ schema: 'git:',
+ });
+});
+
+test('parseLspUrl a uri without schema', () => {
+ const url =
+ 'github.com/Microsoft/vscode/blob/f2e49a2/src/vs/base/parts/ipc/test/node/ipc.net.test.ts';
+ const result = parseLspUrl(url);
+ expect(result).toEqual({
+ uri:
+ '/github.com/Microsoft/vscode/blob/f2e49a2/src/vs/base/parts/ipc/test/node/ipc.net.test.ts',
+ repoUri: 'github.com/Microsoft/vscode',
+ pathType: 'blob',
+ revision: 'f2e49a2',
+ file: 'src/vs/base/parts/ipc/test/node/ipc.net.test.ts',
+ });
+});
+
+test('parseLspUrl a tree uri', () => {
+ const uri = 'github.com/Microsoft/vscode/tree/head/src';
+ const result = parseLspUrl(uri);
+ expect(result).toEqual({
+ uri: '/github.com/Microsoft/vscode/tree/head/src',
+ repoUri: 'github.com/Microsoft/vscode',
+ pathType: 'tree',
+ revision: 'head',
+ file: 'src',
+ });
+});
+
+test('touri', () => {
+ const uri =
+ 'git://github.com/Microsoft/vscode/blob/f2e49a2/src/vs/base/parts/ipc/test/node/ipc.net.test.ts';
+ const result = parseLspUrl(uri);
+ expect(result).toEqual({
+ uri:
+ '/github.com/Microsoft/vscode/blob/f2e49a2/src/vs/base/parts/ipc/test/node/ipc.net.test.ts',
+ repoUri: 'github.com/Microsoft/vscode',
+ pathType: 'blob',
+ revision: 'f2e49a2',
+ file: 'src/vs/base/parts/ipc/test/node/ipc.net.test.ts',
+ schema: 'git:',
+ });
+ const convertBack = toCanonicalUrl(result!);
+ expect(convertBack).toEqual(uri);
+});
+
+test('toRepoName', () => {
+ const uri: RepositoryUri = 'github.com/elastic/elasticsearch';
+ expect(toRepoName(uri)).toEqual('elasticsearch');
+
+ const invalidUri: RepositoryUri = 'github.com/elastic/elasticsearch/invalid';
+ expect(() => {
+ toRepoName(invalidUri);
+ }).toThrow();
+});
+
+test('toRepoNameWithOrg', () => {
+ const uri: RepositoryUri = 'github.com/elastic/elasticsearch';
+ expect(toRepoNameWithOrg(uri)).toEqual('elastic/elasticsearch');
+
+ const invalidUri: RepositoryUri = 'github.com/elastic/elasticsearch/invalid';
+ expect(() => {
+ toRepoNameWithOrg(invalidUri);
+ }).toThrow();
+});
diff --git a/x-pack/plugins/code/common/uri_util.ts b/x-pack/plugins/code/common/uri_util.ts
new file mode 100644
index 0000000000000..706576039efb4
--- /dev/null
+++ b/x-pack/plugins/code/common/uri_util.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Uri } from 'monaco-editor';
+import pathToRegexp from 'path-to-regexp';
+import { Position } from 'vscode-languageserver-types';
+
+import { RepositoryUri } from '../model';
+import { MAIN, MAIN_ROOT } from '../public/components/routes';
+
+const mainRe = pathToRegexp(MAIN);
+const mainRootRe = pathToRegexp(MAIN_ROOT);
+
+export interface ParsedUrl {
+ schema?: string;
+ uri?: string;
+}
+
+export interface CompleteParsedUrl extends ParsedUrl {
+ repoUri: string;
+ revision: string;
+ pathType?: string;
+ file?: string;
+ schema?: string;
+ position?: Position;
+}
+
+export function parseSchema(url: string): { uri: string; schema?: string } {
+ let [schema, uri] = url.toString().split('//');
+ if (!uri) {
+ uri = schema;
+ // @ts-ignore
+ schema = undefined;
+ }
+ if (!uri.startsWith('/')) {
+ uri = '/' + uri;
+ }
+ return { uri, schema };
+}
+
+export function parseGoto(goto: string): Position | undefined {
+ const regex = /L(\d+)(:\d+)?$/;
+ const m = regex.exec(goto);
+ if (m) {
+ const line = parseInt(m[1], 10);
+ let character = 0;
+ if (m[2]) {
+ character = parseInt(m[2].substring(1), 10);
+ }
+ return {
+ line,
+ character,
+ };
+ }
+}
+
+export function parseLspUrl(url: Uri | string): CompleteParsedUrl {
+ const { schema, uri } = parseSchema(url.toString());
+ const mainParsed = mainRe.exec(uri);
+ const mainRootParsed = mainRootRe.exec(uri);
+ if (mainParsed) {
+ const [resource, org, repo, pathType, revision, file, goto] = mainParsed.slice(1);
+ let position;
+ if (goto) {
+ position = parseGoto(goto);
+ }
+ return {
+ uri: uri.replace(goto, ''),
+ repoUri: `${resource}/${org}/${repo}`,
+ pathType,
+ revision,
+ file,
+ schema,
+ position,
+ };
+ } else if (mainRootParsed) {
+ const [resource, org, repo, pathType, revision] = mainRootParsed.slice(1);
+ return {
+ uri,
+ repoUri: `${resource}/${org}/${repo}`,
+ pathType,
+ revision,
+ schema,
+ };
+ } else {
+ throw new Error('invalid url ' + url);
+ }
+}
+
+/*
+ * From RepositoryUri to repository name.
+ * e.g. github.com/elastic/elasticsearch -> elasticsearch
+ */
+export function toRepoName(uri: RepositoryUri): string {
+ const segs = uri.split('/');
+ if (segs.length !== 3) {
+ throw new Error(`Invalid repository uri ${uri}`);
+ }
+ return segs[2];
+}
+
+/*
+ * From RepositoryUri to repository name with organization prefix.
+ * e.g. github.com/elastic/elasticsearch -> elastic/elasticsearch
+ */
+export function toRepoNameWithOrg(uri: RepositoryUri): string {
+ const segs = uri.split('/');
+ if (segs.length !== 3) {
+ throw new Error(`Invalid repository uri ${uri}`);
+ }
+ return `${segs[1]}/${segs[2]}`;
+}
+
+const compiled = pathToRegexp.compile(MAIN);
+
+export function toCanonicalUrl(lspUrl: CompleteParsedUrl) {
+ const [resource, org, repo] = lspUrl.repoUri!.split('/');
+ if (!lspUrl.pathType) {
+ lspUrl.pathType = 'blob';
+ }
+ let goto;
+ if (lspUrl.position) {
+ goto = `!L${lspUrl.position.line + 1}:${lspUrl.position.character}`;
+ }
+ const data = { resource, org, repo, path: lspUrl.file, goto, ...lspUrl };
+ const uri = decodeURIComponent(compiled(data));
+ return lspUrl.schema ? `${lspUrl.schema}/${uri}` : uri;
+}
diff --git a/x-pack/plugins/code/index.ts b/x-pack/plugins/code/index.ts
new file mode 100644
index 0000000000000..7a4e799cf7fc8
--- /dev/null
+++ b/x-pack/plugins/code/index.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import JoiNamespace from 'joi';
+import moment from 'moment';
+import { resolve } from 'path';
+
+import { init } from './server/init';
+
+export const code = (kibana: any) =>
+ new kibana.Plugin({
+ require: ['kibana', 'elasticsearch', 'xpack_main'],
+ id: 'code',
+ configPrefix: 'xpack.code',
+ publicDir: resolve(__dirname, 'public'),
+
+ uiExports: {
+ app: {
+ title: 'Code (Beta)',
+ main: 'plugins/code/app',
+ euiIconType: 'codeApp',
+ },
+ styleSheetPaths: resolve(__dirname, 'public/index.scss'),
+ },
+ config(Joi: typeof JoiNamespace) {
+ return Joi.object({
+ enabled: Joi.boolean().default(true),
+ queueIndex: Joi.string().default('.code_internal-worker-queue'),
+ // 1 hour by default.
+ queueTimeout: Joi.number().default(moment.duration(1, 'hour').asMilliseconds()),
+ // The frequency which update scheduler executes. 5 minutes by default.
+ updateFrequencyMs: Joi.number().default(moment.duration(5, 'minute').asMilliseconds()),
+ // The frequency which index scheduler executes. 1 day by default.
+ indexFrequencyMs: Joi.number().default(moment.duration(1, 'day').asMilliseconds()),
+ // The frequency which each repo tries to update. 1 hour by default.
+ updateRepoFrequencyMs: Joi.number().default(moment.duration(1, 'hour').asMilliseconds()),
+ // The frequency which each repo tries to index. 1 day by default.
+ indexRepoFrequencyMs: Joi.number().default(moment.duration(1, 'day').asMilliseconds()),
+ lsp: Joi.object({
+ // timeout of a request
+ requestTimeoutMs: Joi.number().default(moment.duration(10, 'second').asMilliseconds()),
+ // if we want the language server run in seperately
+ detach: Joi.boolean().default(false),
+ // whether we want to show more language server logs
+ verbose: Joi.boolean().default(false),
+ }).default(),
+ repos: Joi.array().default([]),
+ security: Joi.object({
+ enableMavenImport: Joi.boolean().default(true),
+ enableGradleImport: Joi.boolean().default(false),
+ installNodeDependency: Joi.boolean().default(true),
+ gitHostWhitelist: Joi.array()
+ .items(Joi.string())
+ .default([
+ 'github.com',
+ 'gitlab.com',
+ 'bitbucket.org',
+ 'gitbox.apache.org',
+ 'eclipse.org',
+ ]),
+ gitProtocolWhitelist: Joi.array()
+ .items(Joi.string())
+ .default(['https', 'git', 'ssh']),
+ enableGitCertCheck: Joi.boolean().default(true),
+ }).default(),
+ maxWorkspace: Joi.number().default(5), // max workspace folder for each language server
+ disableIndexScheduler: Joi.boolean().default(true), // Temp option to disable index scheduler.
+ enableGlobalReference: Joi.boolean().default(false), // Global reference as optional feature for now
+ codeNodeUrl: Joi.string(),
+ }).default();
+ },
+ init,
+ });
diff --git a/x-pack/plugins/code/model/commit.ts b/x-pack/plugins/code/model/commit.ts
new file mode 100644
index 0000000000000..dc62a9c8ac8ba
--- /dev/null
+++ b/x-pack/plugins/code/model/commit.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface CommitInfo {
+ updated: Date;
+ message: string;
+ committer: string;
+ id: string;
+ parents: string[];
+}
+
+export interface ReferenceInfo {
+ name: string;
+ reference: string;
+ commit: CommitInfo;
+ type: ReferenceType;
+}
+
+export enum ReferenceType {
+ BRANCH,
+ TAG,
+ REMOTE_BRANCH,
+ OTHER,
+}
diff --git a/x-pack/plugins/code/model/highlight.ts b/x-pack/plugins/code/model/highlight.ts
new file mode 100644
index 0000000000000..3deacd72eb8a4
--- /dev/null
+++ b/x-pack/plugins/code/model/highlight.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export type CodeLine = Token[];
+
+export interface Token {
+ value: string;
+ scopes: string[];
+ range?: Range;
+}
+
+export interface Range {
+ start: number; // start pos in line
+ end: number;
+ pos?: number; // position in file
+}
diff --git a/x-pack/plugins/code/model/index.ts b/x-pack/plugins/code/model/index.ts
new file mode 100644
index 0000000000000..e662ee51a8079
--- /dev/null
+++ b/x-pack/plugins/code/model/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './highlight';
+export * from './search';
+export * from './repository';
+export * from './task';
+export * from './lsp';
+export * from './workspace';
+export * from './socket';
diff --git a/x-pack/plugins/code/model/lsp.ts b/x-pack/plugins/code/model/lsp.ts
new file mode 100644
index 0000000000000..f7933ddb1ae52
--- /dev/null
+++ b/x-pack/plugins/code/model/lsp.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface LspRequest {
+ method: string;
+ params: any;
+ documentUri?: string; // assert there is only one uri per request for now.
+ resolvedFilePath?: string;
+ workspacePath?: string;
+ workspaceRevision?: string;
+ isNotification?: boolean; // if this is a notification request that doesn't need response
+ timeoutForInitializeMs?: number; // If the language server is initialize, how many milliseconds should we wait for it. Default infinite.
+}
diff --git a/x-pack/plugins/code/model/repository.ts b/x-pack/plugins/code/model/repository.ts
new file mode 100644
index 0000000000000..30e4ba0f71419
--- /dev/null
+++ b/x-pack/plugins/code/model/repository.ts
@@ -0,0 +1,149 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IndexRequest } from './search';
+
+export type RepositoryUri = string;
+
+export interface Repository {
+ /** In the form of git://github.com/lambdalab/lambdalab */
+ uri: RepositoryUri;
+ /** Original Clone Url */
+ url: string;
+ name?: string;
+ org?: string;
+ defaultBranch?: string;
+ revision?: string;
+ protocol?: string;
+ // The timestamp of next update for this repository.
+ nextUpdateTimestamp?: Date;
+ // The timestamp of next index for this repository.
+ nextIndexTimestamp?: Date;
+ // The current indexed revision in Elasticsearch.
+ indexedRevision?: string;
+}
+
+export interface RepositoryConfig {
+ uri: RepositoryUri;
+ disableGo?: boolean;
+ disableJava?: boolean;
+ disableTypescript?: boolean;
+}
+
+export interface FileTree {
+ name: string;
+ type: FileTreeItemType;
+
+ /** Full Path of the tree, don't need to be set by the server */
+ path?: string;
+ /**
+ * Children of the file tree, if it is undefined, then it's a file, if it is null, it means it is a
+ * directory and its children haven't been evaluated.
+ */
+ children?: FileTree[];
+ /**
+ * count of children nodes for current node, use this for pagination
+ */
+ childrenCount?: number;
+ sha1?: string;
+ /**
+ * current repo uri
+ */
+ repoUri?: string;
+}
+
+export function sortFileTree(a: FileTree, b: FileTree) {
+ if (a.type !== b.type) {
+ return b.type - a.type;
+ } else {
+ return a.name.localeCompare(b.name);
+ }
+}
+
+export enum FileTreeItemType {
+ File,
+ Directory,
+ Submodule,
+ Link,
+}
+
+export interface WorkerResult {
+ uri: string;
+}
+
+// TODO(mengwei): create a AbstractGitWorkerResult since we now have an
+// AbstractGitWorker now.
+export interface CloneWorkerResult extends WorkerResult {
+ repo: Repository;
+}
+
+export interface DeleteWorkerResult extends WorkerResult {
+ res: boolean;
+}
+
+export interface UpdateWorkerResult extends WorkerResult {
+ branch: string;
+ revision: string;
+}
+
+export enum IndexStatsKey {
+ File = 'file-added-count',
+ FileDeleted = 'file-deleted-count',
+ Symbol = 'symbol-added-count',
+ SymbolDeleted = 'symbol-deleted-count',
+ Reference = 'reference-added-count',
+ ReferenceDeleted = 'reference-deleted-count',
+}
+export type IndexStats = Map;
+
+export interface IndexWorkerResult extends WorkerResult {
+ revision: string;
+ stats: IndexStats;
+}
+
+export enum WorkerReservedProgress {
+ INIT = 0,
+ COMPLETED = 100,
+ ERROR = -100,
+ TIMEOUT = -200,
+}
+
+export interface WorkerProgress {
+ // Job payload repository uri.
+ uri: string;
+ progress: number;
+ timestamp: Date;
+ revision?: string;
+ errorMessage?: string;
+}
+
+export interface CloneProgress {
+ isCloned?: boolean;
+ receivedObjects: number;
+ indexedObjects: number;
+ totalObjects: number;
+ localObjects: number;
+ totalDeltas: number;
+ indexedDeltas: number;
+ receivedBytes: number;
+}
+
+export interface CloneWorkerProgress extends WorkerProgress {
+ cloneProgress?: CloneProgress;
+}
+
+export interface IndexProgress {
+ type: string;
+ total: number;
+ success: number;
+ fail: number;
+ percentage: number;
+ checkpoint?: IndexRequest;
+}
+
+export interface IndexWorkerProgress extends WorkerProgress {
+ indexProgress?: IndexProgress;
+}
diff --git a/x-pack/plugins/code/model/search.ts b/x-pack/plugins/code/model/search.ts
new file mode 100644
index 0000000000000..cee855b0980fd
--- /dev/null
+++ b/x-pack/plugins/code/model/search.ts
@@ -0,0 +1,155 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DetailSymbolInformation } from '@elastic/lsp-extension';
+import { IRange } from 'monaco-editor';
+
+import { DiffKind } from '../common/git_diff';
+import { Repository, SourceHit } from '../model';
+import { RepositoryUri } from './repository';
+
+export interface Document {
+ repoUri: RepositoryUri;
+ path: string;
+ content: string;
+ qnames: string[];
+ language?: string;
+ sha1?: string;
+}
+
+// The base interface of indexer requests
+export interface IndexRequest {
+ repoUri: RepositoryUri;
+}
+
+// The request for LspIndexer
+export interface LspIndexRequest extends IndexRequest {
+ localRepoPath: string; // The repository local file path
+ filePath: string; // The file path within the repository
+ revision: string; // The revision of the current repository
+}
+
+export interface LspIncIndexRequest extends LspIndexRequest {
+ originPath?: string;
+ kind: DiffKind;
+ originRevision: string;
+}
+
+// The request for RepositoryIndexer
+export interface RepositoryIndexRequest extends IndexRequest {
+ repoUri: RepositoryUri;
+}
+
+// The base interface of any kind of search requests.
+export interface SearchRequest {
+ query: string;
+ page: number;
+ resultsPerPage?: number;
+}
+
+export interface RepositorySearchRequest extends SearchRequest {
+ query: string;
+ repoScope?: RepositoryUri[];
+}
+
+export interface DocumentSearchRequest extends SearchRequest {
+ query: string;
+ // repoFilters is used for search within these repos but return
+ // search stats across all repositories.
+ repoFilters?: string[];
+ // repoScope hard limit the search coverage only to these repositories.
+ repoScope?: RepositoryUri[];
+ langFilters?: string[];
+}
+export interface SymbolSearchRequest extends SearchRequest {
+ query: string;
+ repoScope?: RepositoryUri[];
+}
+
+// The base interface of any kind of search result.
+export interface SearchResult {
+ total: number;
+ took: number;
+}
+
+export interface RepositorySearchResult extends SearchResult {
+ repositories: Repository[];
+ from?: number;
+ page?: number;
+ totalPage?: number;
+}
+
+export interface SymbolSearchResult extends SearchResult {
+ // TODO: we migit need an additional data structure for symbol search result.
+ symbols: DetailSymbolInformation[];
+}
+
+// All the interfaces for search page
+
+// The item of the search result stats. e.g. Typescript -> 123
+export interface SearchResultStatsItem {
+ name: string;
+ value: number;
+}
+
+export interface SearchResultStats {
+ total: number; // Total number of results
+ from: number; // The beginning of the result range
+ to: number; // The end of the result range
+ page: number; // The page number
+ totalPage: number; // The total number of pages
+ repoStats: SearchResultStatsItem[];
+ languageStats: SearchResultStatsItem[];
+}
+
+export interface CompositeSourceContent {
+ content: string;
+ lineMapping: string[];
+ ranges: IRange[];
+}
+
+export interface SearchResultItem {
+ uri: string;
+ hits: number;
+ filePath: string;
+ language: string;
+ compositeContent: CompositeSourceContent;
+}
+
+export interface DocumentSearchResult extends SearchResult {
+ query: string;
+ from?: number;
+ page?: number;
+ totalPage?: number;
+ stats?: SearchResultStats;
+ results?: SearchResultItem[];
+ repoAggregations?: any[];
+ langAggregations?: any[];
+}
+
+export interface SourceLocation {
+ line: number;
+ column: number;
+ offset: number;
+}
+
+export interface SourceRange {
+ startLoc: SourceLocation;
+ endLoc: SourceLocation;
+}
+
+export interface SourceHit {
+ range: SourceRange;
+ score: number;
+ term: string;
+}
+
+export enum SearchScope {
+ DEFAULT = 'default', // Search everything
+ SYMBOL = 'symbol', // Only search symbols
+ REPOSITORY = 'repository', // Only search repositories
+ FILE = 'file', // Only search files
+}
diff --git a/x-pack/plugins/code/model/socket.ts b/x-pack/plugins/code/model/socket.ts
new file mode 100644
index 0000000000000..f29659e6fd937
--- /dev/null
+++ b/x-pack/plugins/code/model/socket.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export enum SocketKind {
+ CLONE_PROGRESS = 'clone-progress',
+ DELETE_PROGRESS = 'delete-progress',
+ INDEX_PROGRESS = 'index-progress',
+ INSTALL_PROGRESS = 'install-progress',
+}
diff --git a/x-pack/plugins/code/model/task.ts b/x-pack/plugins/code/model/task.ts
new file mode 100644
index 0000000000000..9bc983b9c7f8f
--- /dev/null
+++ b/x-pack/plugins/code/model/task.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RepositoryUri } from './repository';
+
+/** Time consuming task that should be queued and executed seperately */
+export interface Task {
+ repoUri: RepositoryUri;
+ type: TaskType;
+ /** Percentage of the task, 100 means task completed */
+ progress: number;
+
+ /** Revision of the repo that the task run on. May only apply to Index task */
+ revision?: string;
+}
+
+export enum TaskType {
+ Import,
+ Update,
+ Delete,
+ Index,
+}
diff --git a/x-pack/plugins/code/model/test_config.ts b/x-pack/plugins/code/model/test_config.ts
new file mode 100644
index 0000000000000..45f8cf34684ef
--- /dev/null
+++ b/x-pack/plugins/code/model/test_config.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface Repo {
+ url: string;
+ path: string;
+ language: string;
+}
+
+export interface TestConfig {
+ repos: Repo[];
+}
+
+export enum RequestType {
+ INITIALIZE,
+ HOVER,
+ FULL,
+}
diff --git a/x-pack/plugins/code/model/workspace.ts b/x-pack/plugins/code/model/workspace.ts
new file mode 100644
index 0000000000000..efa48e28e7388
--- /dev/null
+++ b/x-pack/plugins/code/model/workspace.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export type RepoCmd = string | string[];
+export interface RepoConfig {
+ repo: string;
+ init: RepoCmd;
+}
+
+export interface RepoConfigs {
+ [repoUri: string]: RepoConfig;
+}
diff --git a/x-pack/plugins/code/public/actions/blame.ts b/x-pack/plugins/code/public/actions/blame.ts
new file mode 100644
index 0000000000000..e78a0d05eeffb
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/blame.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { GitBlame } from '../../common/git_blame';
+
+export interface LoadBlamePayload {
+ repoUri: string;
+ revision: string;
+ path: string;
+}
+
+export const loadBlame = createAction('LOAD BLAME');
+export const loadBlameSuccess = createAction('LOAD BLAME SUCCESS');
+export const loadBlameFailed = createAction('LOAD BLAME FAILED');
diff --git a/x-pack/plugins/code/public/actions/commit.ts b/x-pack/plugins/code/public/actions/commit.ts
new file mode 100644
index 0000000000000..aabf0e179beed
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/commit.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { CommitDiff } from '../../common/git_diff';
+
+export const loadCommit = createAction('LOAD COMMIT');
+export const loadCommitSuccess = createAction('LOAD COMMIT SUCCESS');
+export const loadCommitFailed = createAction('LOAD COMMIT FAILED');
diff --git a/x-pack/plugins/code/public/actions/editor.ts b/x-pack/plugins/code/public/actions/editor.ts
new file mode 100644
index 0000000000000..146c54fca0fa1
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/editor.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Range } from 'monaco-editor';
+import { createAction } from 'redux-actions';
+import { Hover, Position, TextDocumentPositionParams } from 'vscode-languageserver';
+
+export interface ReferenceResults {
+ repos: GroupedRepoReferences[];
+ title: string;
+}
+
+export interface GroupedRepoReferences {
+ repo: string;
+ files: GroupedFileReferences[];
+}
+
+export interface GroupedFileReferences {
+ uri: string;
+ file: string;
+ language: string;
+ code: string;
+ lineNumbers: string[];
+ repo: string;
+ revision: string;
+ highlights: Range[];
+}
+
+export const findReferences = createAction('FIND REFERENCES');
+export const findReferencesSuccess = createAction('FIND REFERENCES SUCCESS');
+export const findReferencesFailed = createAction('FIND REFERENCES ERROR');
+export const closeReferences = createAction('CLOSE REFERENCES');
+export const hoverResult = createAction('HOVER RESULT');
+export const revealPosition = createAction('REVEAL POSITION');
diff --git a/x-pack/plugins/code/public/actions/file.ts b/x-pack/plugins/code/public/actions/file.ts
new file mode 100644
index 0000000000000..b91ead5889685
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/file.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { FileTree } from '../../model';
+import { CommitInfo, ReferenceInfo } from '../../model/commit';
+
+export interface FetchRepoPayload {
+ uri: string;
+}
+
+export interface FetchRepoPayloadWithRevision extends FetchRepoPayload {
+ revision: string;
+}
+export interface FetchFilePayload extends FetchRepoPayloadWithRevision {
+ path: string;
+}
+export interface FetchRepoTreePayload extends FetchFilePayload {
+ limit?: number;
+ parents?: boolean;
+ isDir: boolean;
+}
+
+export interface FetchFileResponse {
+ payload: FetchFilePayload;
+ isNotFound?: boolean;
+ content?: string;
+ lang?: string;
+ isImage?: boolean;
+ isUnsupported?: boolean;
+ isOversize?: boolean;
+ url?: string;
+}
+
+export interface RepoTreePayload {
+ tree: FileTree;
+ path: string;
+ withParents: boolean | undefined;
+}
+
+export const fetchRepoTree = createAction('FETCH REPO TREE');
+export const fetchRepoTreeSuccess = createAction('FETCH REPO TREE SUCCESS');
+export const fetchRepoTreeFailed = createAction('FETCH REPO TREE FAILED');
+export const resetRepoTree = createAction('CLEAR REPO TREE');
+export const closeTreePath = createAction('CLOSE TREE PATH');
+export const openTreePath = createAction('OPEN TREE PATH');
+
+export const fetchRepoBranches = createAction('FETCH REPO BRANCHES');
+export const fetchRepoBranchesSuccess = createAction(
+ 'FETCH REPO BRANCHES SUCCESS'
+);
+export const fetchRepoBranchesFailed = createAction('FETCH REPO BRANCHES FAILED');
+export const fetchRepoCommits = createAction('FETCH REPO COMMITS');
+export const fetchRepoCommitsSuccess = createAction('FETCH REPO COMMITS SUCCESS');
+export const fetchRepoCommitsFailed = createAction('FETCH REPO COMMITS FAILED');
+
+export const fetchFile = createAction('FETCH FILE');
+export const fetchFileSuccess = createAction('FETCH FILE SUCCESS');
+export const fetchFileFailed = createAction('FETCH FILE ERROR');
+
+export const fetchDirectory = createAction('FETCH REPO DIR');
+export const fetchDirectorySuccess = createAction('FETCH REPO DIR SUCCESS');
+export const fetchDirectoryFailed = createAction('FETCH REPO DIR FAILED');
+export const setNotFound = createAction('SET NOT FOUND');
+
+export const fetchTreeCommits = createAction('FETCH TREE COMMITS');
+export const fetchTreeCommitsSuccess = createAction<{
+ path: string;
+ commits: CommitInfo[];
+ append?: boolean;
+}>('FETCH TREE COMMITS SUCCESS');
+export const fetchTreeCommitsFailed = createAction('FETCH TREE COMMITS FAILED');
+
+export const fetchMoreCommits = createAction('FETCH MORE COMMITS');
diff --git a/x-pack/plugins/code/public/actions/index.ts b/x-pack/plugins/code/public/actions/index.ts
new file mode 100644
index 0000000000000..6f12693e43bdd
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/index.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+
+export * from './repository';
+export * from './search';
+export * from './file';
+export * from './structure';
+export * from './editor';
+export * from './commit';
+export * from './status';
+export * from './project_config';
+export * from './shortcuts';
+
+export interface Match {
+ isExact?: boolean;
+ params: { [key: string]: string };
+ path: string;
+ url: string;
+ location: Location;
+}
+
+export const routeChange = createAction('CODE SEARCH ROUTE CHANGE');
+
+export const checkSetupSuccess = createAction('SETUP CHECK SUCCESS');
+export const checkSetupFailed = createAction('SETUP CHECK FAILED');
diff --git a/x-pack/plugins/code/public/actions/language_server.ts b/x-pack/plugins/code/public/actions/language_server.ts
new file mode 100644
index 0000000000000..9e4f22779150b
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/language_server.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+
+export const loadLanguageServers = createAction('LOAD LANGUAGE SERVERS');
+export const loadLanguageServersSuccess = createAction('LOAD LANGUAGE SERVERS SUCCESS');
+export const loadLanguageServersFailed = createAction('LOAD LANGUAGE SERVERS FAILED');
+
+export const requestInstallLanguageServer = createAction('REQUEST INSTALL LANGUAGE SERVERS');
+export const requestInstallLanguageServerSuccess = createAction(
+ 'REQUEST INSTALL LANGUAGE SERVERS SUCCESS'
+);
+export const requestInstallLanguageServerFailed = createAction(
+ 'REQUEST INSTALL LANGUAGE SERVERS FAILED'
+);
+
+export const installLanguageServerSuccess = createAction('INSTALL LANGUAGE SERVERS SUCCESS');
diff --git a/x-pack/plugins/code/public/actions/project_config.ts b/x-pack/plugins/code/public/actions/project_config.ts
new file mode 100644
index 0000000000000..05de33da7f855
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/project_config.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { RepositoryConfig } from '../../model';
+
+export const loadConfigs = createAction('LOAD CONFIGS');
+export const loadConfigsSuccess = createAction<{ [key: string]: RepositoryConfig }>(
+ 'LOAD CONFIGS SUCCESS'
+);
+export const loadConfigsFailed = createAction('LOAD CONFIGS FAILED');
diff --git a/x-pack/plugins/code/public/actions/recent_projects.ts b/x-pack/plugins/code/public/actions/recent_projects.ts
new file mode 100644
index 0000000000000..e278744f10c76
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/recent_projects.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+
+export const loadRecentProjects = createAction('LOAD RECENT PROJECTS');
+export const loadRecentProjectsSuccess = createAction('LOAD RECENT PROJECTS SUCCESS');
+export const loadRecentProjectsFailed = createAction('LOAD RECENT PROJECTS FAILED');
diff --git a/x-pack/plugins/code/public/actions/repository.ts b/x-pack/plugins/code/public/actions/repository.ts
new file mode 100644
index 0000000000000..1ab96d449b5f9
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/repository.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+
+import { Repository, RepositoryConfig } from '../../model';
+import { RepoConfigs } from '../../model/workspace';
+
+export interface RepoConfigPayload {
+ repoUri: string;
+ config: RepositoryConfig;
+}
+
+export const fetchRepos = createAction('FETCH REPOS');
+export const fetchReposSuccess = createAction('FETCH REPOS SUCCESS');
+export const fetchReposFailed = createAction('FETCH REPOS FAILED');
+
+export const deleteRepo = createAction('DELETE REPOS');
+export const deleteRepoSuccess = createAction('DELETE REPOS SUCCESS');
+export const deleteRepoFinished = createAction('DELETE REPOS FINISHED');
+export const deleteRepoFailed = createAction('DELETE REPOS FAILED');
+
+export const indexRepo = createAction('INDEX REPOS');
+export const indexRepoSuccess = createAction('INDEX REPOS SUCCESS');
+export const indexRepoFailed = createAction('INDEX REPOS FAILED');
+
+export const importRepo = createAction('IMPORT REPO');
+export const importRepoSuccess = createAction('IMPORT REPO SUCCESS');
+export const importRepoFailed = createAction('IMPORT REPO FAILED');
+
+export const closeToast = createAction('CLOSE TOAST');
+
+export const fetchRepoConfigs = createAction('FETCH REPO CONFIGS');
+export const fetchRepoConfigSuccess = createAction('FETCH REPO CONFIGS SUCCESS');
+export const fetchRepoConfigFailed = createAction('FETCH REPO CONFIGS FAILED');
+
+export const initRepoCommand = createAction('INIT REPO CMD');
+
+export const gotoRepo = createAction('GOTO REPO');
+
+export const switchLanguageServer = createAction('SWITCH LANGUAGE SERVER');
+export const switchLanguageServerSuccess = createAction('SWITCH LANGUAGE SERVER SUCCESS');
+export const switchLanguageServerFailed = createAction('SWITCH LANGUAGE SERVER FAILED');
diff --git a/x-pack/plugins/code/public/actions/search.ts b/x-pack/plugins/code/public/actions/search.ts
new file mode 100644
index 0000000000000..d6dac604fb5f0
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/search.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { DocumentSearchResult, Repository, SearchScope } from '../../model';
+
+export interface DocumentSearchPayload {
+ query: string;
+ page?: string;
+ languages?: string;
+ repositories?: string;
+ repoScope?: string;
+}
+
+export interface RepositorySearchPayload {
+ query: string;
+}
+
+export interface SearchOptions {
+ repoScope: Repository[];
+ defaultRepoScopeOn: boolean;
+}
+
+// For document search page
+export const documentSearch = createAction('DOCUMENT SEARCH');
+export const documentSearchSuccess = createAction('DOCUMENT SEARCH SUCCESS');
+export const documentSearchFailed = createAction('DOCUMENT SEARCH FAILED');
+
+// For repository search page
+export const repositorySearch = createAction('REPOSITORY SEARCH');
+export const repositorySearchSuccess = createAction('REPOSITORY SEARCH SUCCESS');
+export const repositorySearchFailed = createAction('REPOSITORY SEARCH FAILED');
+
+export const changeSearchScope = createAction('CHANGE SEARCH SCOPE');
+
+// For repository search typeahead
+export const repositorySearchQueryChanged = createAction(
+ 'REPOSITORY SEARCH QUERY CHANGED'
+);
+export const repositoryTypeaheadSearchSuccess = createAction('REPOSITORY SEARCH SUCCESS');
+export const repositoryTypeaheadSearchFailed = createAction('REPOSITORY SEARCH FAILED');
+
+export const saveSearchOptions = createAction('SAVE SEARCH OPTIONS');
+
+export const turnOnDefaultRepoScope = createAction('TURN ON DEFAULT REPO SCOPE');
+
+export const searchReposForScope = createAction('SEARCH REPOS FOR SCOPE');
+export const searchReposForScopeSuccess = createAction('SEARCH REPOS FOR SCOPE SUCCESS');
+export const searchReposForScopeFailed = createAction('SEARCH REPOS FOR SCOPE FAILED');
diff --git a/x-pack/plugins/code/public/actions/shortcuts.ts b/x-pack/plugins/code/public/actions/shortcuts.ts
new file mode 100644
index 0000000000000..14f93b220d466
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/shortcuts.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { HotKey } from '../components/shortcuts';
+
+export const registerShortcut = createAction('REGISTER SHORTCUT');
+export const unregisterShortcut = createAction('UNREGISTER SHORTCUT');
+
+export const toggleHelp = createAction('TOGGLE SHORTCUTS HELP');
diff --git a/x-pack/plugins/code/public/actions/status.ts b/x-pack/plugins/code/public/actions/status.ts
new file mode 100644
index 0000000000000..4a29d0d772664
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/status.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { RepoStatus } from '../reducers';
+
+export const loadStatus = createAction('LOAD STATUS');
+export const loadStatusSuccess = createAction('LOAD STATUS SUCCESS');
+export const loadStatusFailed = createAction('LOAD STATUS FAILED');
+
+export const pollRepoCloneStatus = createAction('POLL CLONE STATUS');
+export const pollRepoIndexStatus = createAction('POLL INDEX STATUS');
+export const pollRepoDeleteStatus = createAction('POLL DELETE STATUS');
+
+export const loadRepo = createAction('LOAD REPO');
+export const loadRepoSuccess = createAction('LOAD REPO SUCCESS');
+export const loadRepoFailed = createAction('LOAD REPO FAILED');
+
+export const updateCloneProgress = createAction('UPDATE CLONE PROGRESS');
+export const updateIndexProgress = createAction('UPDATE INDEX PROGRESS');
+export const updateDeleteProgress = createAction('UPDATE DELETE PROGRESS');
diff --git a/x-pack/plugins/code/public/actions/structure.ts b/x-pack/plugins/code/public/actions/structure.ts
new file mode 100644
index 0000000000000..5b7639d95619f
--- /dev/null
+++ b/x-pack/plugins/code/public/actions/structure.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createAction } from 'redux-actions';
+import { SymbolInformation } from 'vscode-languageserver-types/lib/esm/main';
+
+export interface SymbolsPayload {
+ path: string;
+ data: SymbolInformation[];
+}
+
+export const loadStructure = createAction('LOAD STRUCTURE');
+export const loadStructureSuccess = createAction('LOAD STRUCTURE SUCCESS');
+export const loadStructureFailed = createAction('LOAD STRUCTURE FAILED');
+
+export const openSymbolPath = createAction('OPEN SYMBOL PATH');
+export const closeSymbolPath = createAction('CLOSE SYMBOL PATH');
diff --git a/x-pack/plugins/code/public/app.tsx b/x-pack/plugins/code/public/app.tsx
new file mode 100644
index 0000000000000..66e7965d4f578
--- /dev/null
+++ b/x-pack/plugins/code/public/app.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import { Provider } from 'react-redux';
+import 'ui/autoload/all';
+import 'ui/autoload/styles';
+import chrome from 'ui/chrome';
+// @ts-ignore
+import { uiModules } from 'ui/modules';
+import { App } from './components/app';
+import { HelpMenu } from './components/help_menu';
+import { store } from './stores';
+
+const app = uiModules.get('apps/code');
+
+app.config(($locationProvider: any) => {
+ $locationProvider.html5Mode({
+ enabled: false,
+ requireBase: false,
+ rewriteLinks: false,
+ });
+});
+app.config((stateManagementConfigProvider: any) => stateManagementConfigProvider.disable());
+
+function RootController($scope: any, $element: any, $http: any) {
+ const domNode = $element[0];
+
+ // render react to DOM
+ render(
+
+
+ ,
+ domNode
+ );
+
+ // unmount react on controller destroy
+ $scope.$on('$destroy', () => {
+ unmountComponentAtNode(domNode);
+ });
+}
+
+chrome.setRootController('code', RootController);
+chrome.breadcrumbs.set([
+ {
+ text: 'Code (Beta)',
+ href: '#/',
+ },
+]);
+
+chrome.helpExtension.set(domNode => {
+ render( , domNode);
+ return () => {
+ unmountComponentAtNode(domNode);
+ };
+});
diff --git a/x-pack/plugins/code/public/common/types.ts b/x-pack/plugins/code/public/common/types.ts
new file mode 100644
index 0000000000000..c2122022b9d84
--- /dev/null
+++ b/x-pack/plugins/code/public/common/types.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ReactNode } from 'react';
+import { SearchScope } from '../../model';
+
+export enum PathTypes {
+ blob = 'blob',
+ tree = 'tree',
+ blame = 'blame',
+ commits = 'commits',
+}
+
+export const SearchScopeText = {
+ [SearchScope.DEFAULT]: 'Search Everything',
+ [SearchScope.REPOSITORY]: 'Search Repositories',
+ [SearchScope.SYMBOL]: 'Search Symbols',
+ [SearchScope.FILE]: 'Search Files',
+};
+
+export const SearchScopePlaceholderText = {
+ [SearchScope.DEFAULT]: 'Type to find anything',
+ [SearchScope.REPOSITORY]: 'Type to find repositories',
+ [SearchScope.SYMBOL]: 'Type to find symbols',
+ [SearchScope.FILE]: 'Type to find files',
+};
+
+export interface MainRouteParams {
+ path: string;
+ repo: string;
+ resource: string;
+ org: string;
+ revision: string;
+ pathType: PathTypes;
+ goto?: string;
+}
+
+export interface EuiSideNavItem {
+ id: string;
+ name: string;
+ isSelected?: boolean;
+ renderItem?: () => ReactNode;
+ forceOpen?: boolean;
+ items?: EuiSideNavItem[];
+ onClick: () => void;
+}
diff --git a/x-pack/plugins/code/public/components/admin_page/admin.tsx b/x-pack/plugins/code/public/components/admin_page/admin.tsx
new file mode 100644
index 0000000000000..19ed136732473
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/admin.tsx
@@ -0,0 +1,132 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { parse as parseQuery } from 'querystring';
+import React from 'react';
+import { connect } from 'react-redux';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import url from 'url';
+import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs } from '@elastic/eui';
+import { Repository } from '../../../model';
+import { RootState } from '../../reducers';
+import { EmptyProject } from './empty_project';
+import { LanguageSeverTab } from './language_server_tab';
+import { ProjectTab } from './project_tab';
+
+enum AdminTabs {
+ projects = 'Projects',
+ roles = 'Roles',
+ languageServers = 'LanguageServers',
+}
+
+interface Props extends RouteComponentProps {
+ repositories: Repository[];
+ repositoryLoading: boolean;
+}
+
+interface State {
+ tab: AdminTabs;
+}
+
+class AdminPage extends React.PureComponent {
+ public static getDerivedStateFromProps(props: Props) {
+ const getTab = () => {
+ const { search } = props.location;
+ let qs = search;
+ if (search.charAt(0) === '?') {
+ qs = search.substr(1);
+ }
+ return parseQuery(qs).tab || AdminTabs.projects;
+ };
+ return {
+ tab: getTab() as AdminTabs,
+ };
+ }
+ public tabs = [
+ {
+ id: AdminTabs.projects,
+ name: AdminTabs.projects,
+ disabled: false,
+ },
+ {
+ id: AdminTabs.languageServers,
+ name: 'Language servers',
+ disabled: false,
+ },
+ ];
+ constructor(props: Props) {
+ super(props);
+ const getTab = () => {
+ const { search } = props.location;
+ let qs = search;
+ if (search.charAt(0) === '?') {
+ qs = search.substr(1);
+ }
+ return parseQuery(qs).tab || AdminTabs.projects;
+ };
+ this.state = {
+ tab: getTab() as AdminTabs,
+ };
+ }
+
+ public getAdminTabClickHandler = (tab: AdminTabs) => () => {
+ this.setState({ tab });
+ this.props.history.push(url.format({ pathname: '/admin', query: { tab } }));
+ };
+
+ public renderTabs() {
+ const tabs = this.tabs.map(tab => (
+
+ {tab.name}
+
+ ));
+ return {tabs} ;
+ }
+
+ public filterRepos = () => {
+ return this.props.repositories;
+ };
+
+ public renderTabContent = () => {
+ switch (this.state.tab) {
+ case AdminTabs.languageServers: {
+ return ;
+ }
+ case AdminTabs.projects:
+ default: {
+ const repositoriesCount = this.props.repositories.length;
+ const showEmpty = repositoriesCount === 0 && !this.props.repositoryLoading;
+ if (showEmpty) {
+ return ;
+ }
+ return ;
+ }
+ }
+ };
+
+ public render() {
+ return (
+
+
+ {this.renderTabs()}
+ {this.renderTabContent()}
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ repositories: state.repository.repositories,
+ repositoryLoading: state.repository.loading,
+});
+
+export const Admin = withRouter(connect(mapStateToProps)(AdminPage));
diff --git a/x-pack/plugins/code/public/components/admin_page/empty_project.tsx b/x-pack/plugins/code/public/components/admin_page/empty_project.tsx
new file mode 100644
index 0000000000000..d64e44b3013da
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/empty_project.tsx
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import { EuiButton, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui';
+import { capabilities } from 'ui/capabilities';
+
+import { ImportProject } from './import_project';
+
+export const EmptyProject = () => {
+ const isAdmin = capabilities.get().code.admin as boolean;
+ return (
+
+
+
+
+ You don't have any projects yet
+
+
{isAdmin && Let's import your first one
}
+
+ {isAdmin &&
}
+
+
+
+ View the Setup Guide
+
+
+
+ );
+};
diff --git a/x-pack/plugins/code/public/components/admin_page/import_project.tsx b/x-pack/plugins/code/public/components/admin_page/import_project.tsx
new file mode 100644
index 0000000000000..dd8e82686f1f4
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/import_project.tsx
@@ -0,0 +1,124 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiGlobalToastList,
+ EuiSpacer,
+} from '@elastic/eui';
+import React, { ChangeEvent } from 'react';
+import { connect } from 'react-redux';
+import { closeToast, importRepo } from '../../actions';
+import { RootState } from '../../reducers';
+import { ToastType } from '../../reducers/repository';
+import { isImportRepositoryURLInvalid } from '../../utils/url';
+
+class CodeImportProject extends React.PureComponent<
+ {
+ importRepo: (p: string) => void;
+ importLoading: boolean;
+ toastMessage?: string;
+ showToast: boolean;
+ toastType?: ToastType;
+ closeToast: () => void;
+ },
+ { value: string; isInvalid: boolean }
+> {
+ public state = {
+ value: '',
+ isInvalid: false,
+ };
+
+ public onChange = (e: ChangeEvent) => {
+ this.setState({
+ value: e.target.value,
+ isInvalid: isImportRepositoryURLInvalid(e.target.value),
+ });
+ };
+
+ public submitImportProject = () => {
+ if (!isImportRepositoryURLInvalid(this.state.value)) {
+ this.props.importRepo(this.state.value);
+ } else if (!this.state.isInvalid) {
+ this.setState({ isInvalid: true });
+ }
+ };
+
+ public updateIsInvalid = () => {
+ this.setState({ isInvalid: isImportRepositoryURLInvalid(this.state.value) });
+ };
+
+ public render() {
+ const { importLoading, toastMessage, showToast, toastType } = this.props;
+
+ return (
+
+ {showToast && (
+
+ )}
+
+
+
+
+
+
+
+
+ {/*
+ // @ts-ignore */}
+
+ Import
+
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ importLoading: state.repository.importLoading,
+ toastMessage: state.repository.toastMessage,
+ toastType: state.repository.toastType,
+ showToast: state.repository.showToast,
+});
+
+const mapDispatchToProps = {
+ importRepo,
+ closeToast,
+};
+
+export const ImportProject = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(CodeImportProject);
diff --git a/x-pack/plugins/code/public/components/admin_page/language_server_tab.tsx b/x-pack/plugins/code/public/components/admin_page/language_server_tab.tsx
new file mode 100644
index 0000000000000..8e31073726def
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/language_server_tab.tsx
@@ -0,0 +1,240 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiCodeBlock,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiOverlayMask,
+ EuiPanel,
+ EuiSpacer,
+ EuiTabbedContent,
+ EuiText,
+} from '@elastic/eui';
+import React from 'react';
+import { connect } from 'react-redux';
+import { InstallationType } from '../../../common/installation';
+import { LanguageServer, LanguageServerStatus } from '../../../common/language_server';
+import { requestInstallLanguageServer } from '../../actions/language_server';
+import { RootState } from '../../reducers';
+import { JavaIcon, TypeScriptIcon, GoIcon } from '../shared/icons';
+
+const LanguageServerLi = (props: {
+ languageServer: LanguageServer;
+ requestInstallLanguageServer: (l: string) => void;
+ loading: boolean;
+}) => {
+ const { status, name } = props.languageServer;
+
+ const languageIcon = () => {
+ if (name === 'Typescript') {
+ return ;
+ } else if (name === 'Java') {
+ return ;
+ } else if (name === 'Go') {
+ return ;
+ }
+ };
+
+ const onInstallClick = () => props.requestInstallLanguageServer(name);
+ let button = null;
+ let state = null;
+ if (status === LanguageServerStatus.RUNNING) {
+ state = Running ... ;
+ } else if (status === LanguageServerStatus.NOT_INSTALLED) {
+ state = (
+
+ Not Installed
+
+ );
+ } else if (status === LanguageServerStatus.READY) {
+ state = (
+
+ Installed
+
+ );
+ }
+ if (props.languageServer.installationType === InstallationType.Plugin) {
+ button = (
+
+ Setup
+
+ );
+ }
+ return (
+
+
+
+
+
+ {languageIcon()}
+
+
+ {name}
+
+
+ {state}
+
+
+
+
+ {button}
+
+
+
+ );
+};
+
+interface Props {
+ languageServers: LanguageServer[];
+ requestInstallLanguageServer: (ls: string) => void;
+ installLoading: { [ls: string]: boolean };
+}
+interface State {
+ showingInstruction: boolean;
+ name?: string;
+ url?: string;
+ pluginName?: string;
+}
+
+class AdminLanguageSever extends React.PureComponent {
+ constructor(props: Props, context: any) {
+ super(props, context);
+ this.state = { showingInstruction: false };
+ }
+
+ public toggleInstruction = (
+ showingInstruction: boolean,
+ name?: string,
+ url?: string,
+ pluginName?: string
+ ) => {
+ this.setState({ showingInstruction, name, url, pluginName });
+ };
+
+ public render() {
+ const languageServers = this.props.languageServers.map(ls => (
+
+ this.toggleInstruction(true, ls.name, ls.downloadUrl, ls.pluginName)
+ }
+ loading={this.props.installLoading[ls.name]}
+ />
+ ));
+ return (
+
+
+
+
+ {this.props.languageServers.length}
+ {this.props.languageServers.length > 1 ? (
+ Language servers
+ ) : (
+ Language server
+ )}
+
+
+
+
+ {languageServers}
+
+ this.toggleInstruction(false)}
+ />
+
+ );
+ }
+}
+
+const SupportedOS = [
+ { id: 'win', name: 'Windows' },
+ { id: 'linux', name: 'Linux' },
+ { id: 'darwin', name: 'macOS' },
+];
+
+const LanguageServerInstruction = (props: {
+ name: string;
+ pluginName: string;
+ url: string;
+ show: boolean;
+ close: () => void;
+}) => {
+ const tabs = SupportedOS.map(({ id, name }) => {
+ const url = props.url ? props.url.replace('$OS', id) : '';
+ const installCode = `bin/kibana-plugin install ${url}`;
+ return {
+ id,
+ name,
+ content: (
+
+ Install
+
+ Stop your kibana Code node, then use the following command to install {props.name}{' '}
+ Language Server plugin:
+ {installCode}
+
+ Uninstall
+
+ Stop your kibana Code node, then use the following command to remove {props.name}{' '}
+ Language Server plugin:
+
+ bin/kibana-plugin remove {props.pluginName}
+
+
+
+ ),
+ };
+ });
+
+ return (
+
+ {' '}
+ {props.show && (
+
+
+
+ Install Instruction
+
+
+
+
+
+
+ Close
+
+
+
+
+ )}
+
+ );
+};
+
+const mapStateToProps = (state: RootState) => ({
+ languageServers: state.languageServer.languageServers,
+ installLoading: state.languageServer.installServerLoading,
+});
+
+const mapDispatchToProps = {
+ requestInstallLanguageServer,
+};
+
+export const LanguageSeverTab = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(AdminLanguageSever);
diff --git a/x-pack/plugins/code/public/components/admin_page/project_item.tsx b/x-pack/plugins/code/public/components/admin_page/project_item.tsx
new file mode 100644
index 0000000000000..6e59eff899946
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/project_item.tsx
@@ -0,0 +1,238 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiIcon,
+ EuiPanel,
+ EuiProgress,
+ EuiText,
+ EuiTextColor,
+ EuiToolTip,
+} from '@elastic/eui';
+import moment from 'moment';
+import React from 'react';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+import { Repository, WorkerReservedProgress } from '../../../model';
+import { deleteRepo, indexRepo, initRepoCommand } from '../../actions';
+import { RepoState, RepoStatus } from '../../reducers/status';
+
+const stateColor = {
+ [RepoState.CLONING]: 'secondary',
+ [RepoState.DELETING]: 'accent',
+ [RepoState.INDEXING]: 'primary',
+};
+
+class CodeProjectItem extends React.PureComponent<{
+ project: Repository;
+ enableManagement: boolean;
+ showStatus: boolean;
+ status?: RepoStatus;
+ deleteRepo?: (uri: string) => void;
+ indexRepo?: (uri: string) => void;
+ initRepoCommand?: (uri: string) => void;
+ openSettings?: (uri: string, url: string) => void;
+}> {
+ public render() {
+ const { project, showStatus, status, enableManagement } = this.props;
+ const { name, org, uri, url } = project;
+ const onClickDelete = () => this.props.deleteRepo && this.props.deleteRepo(uri);
+ const onClickIndex = () => this.props.indexRepo && this.props.indexRepo(uri);
+ const onClickSettings = () => this.props.openSettings && this.props.openSettings(uri, url);
+ let footer = null;
+ let disableRepoLink = false;
+ let hasError = false;
+ if (!status) {
+ footer = INIT...
;
+ } else if (status.state === RepoState.READY) {
+ footer = (
+
+ LAST UPDATED: {moment(status.timestamp).fromNow()}
+
+ );
+ } else if (status.state === RepoState.DELETING) {
+ footer = DELETING...
;
+ } else if (status.state === RepoState.INDEXING) {
+ footer = (
+
+ INDEXING...
+
+ );
+ } else if (status.state === RepoState.CLONING) {
+ footer = CLONING...
;
+ } else if (status.state === RepoState.DELETE_ERROR) {
+ footer = ERROR DELETE REPO
;
+ hasError = true;
+ } else if (status.state === RepoState.INDEX_ERROR) {
+ footer = ERROR INDEX REPO
;
+ hasError = true;
+ } else if (status.state === RepoState.CLONE_ERROR) {
+ footer = (
+
+ ERROR CLONING REPO
+
+
+
+
+ );
+ // Disable repo link is clone failed.
+ disableRepoLink = true;
+ hasError = true;
+ }
+
+ const repoTitle = (
+
+ {org} /{name}
+
+ );
+
+ const settingsShow =
+ status && status.state !== RepoState.CLONING && status.state !== RepoState.DELETING;
+ const settingsVisibility = settingsShow ? 'visible' : 'hidden';
+
+ const indexShow =
+ status &&
+ status.state !== RepoState.CLONING &&
+ status.state !== RepoState.DELETING &&
+ status.state !== RepoState.INDEXING &&
+ status.state !== RepoState.CLONE_ERROR;
+ const indexVisibility = indexShow ? 'visible' : 'hidden';
+
+ const deleteShow = status && status.state !== RepoState.DELETING;
+ const deleteVisibility = deleteShow ? 'visible' : 'hidden';
+
+ const projectManagement = (
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+ Index
+
+
+
+
+
+
+
+ Delete
+
+
+
+
+
+ );
+
+ const repoStatus = (
+
+
+ {footer}
+
+
+ );
+
+ return (
+
+ {this.renderProgress()}
+
+
+ {disableRepoLink ? (
+ repoTitle
+ ) : (
+
+ {repoTitle}
+
+ )}
+ {showStatus ? repoStatus : null}
+
+
+
+
+ {uri}
+
+
+
+ {enableManagement && projectManagement}
+
+
+ );
+ }
+
+ private renderProgress() {
+ const { status } = this.props;
+ if (
+ status &&
+ (status.state === RepoState.CLONING ||
+ status.state === RepoState.DELETING ||
+ status.state === RepoState.INDEXING)
+ ) {
+ const color = stateColor[status.state] as 'primary' | 'secondary' | 'accent';
+ if (status.progress! === WorkerReservedProgress.COMPLETED) {
+ return null;
+ } else if (status.progress! > WorkerReservedProgress.INIT) {
+ return (
+
+ );
+ } else {
+ return ;
+ }
+ }
+ }
+}
+
+const mapDispatchToProps = {
+ deleteRepo,
+ indexRepo,
+ initRepoCommand,
+};
+
+export const ProjectItem = connect(
+ null,
+ mapDispatchToProps
+)(CodeProjectItem);
diff --git a/x-pack/plugins/code/public/components/admin_page/project_settings.tsx b/x-pack/plugins/code/public/components/admin_page/project_settings.tsx
new file mode 100644
index 0000000000000..5a100cb573ca1
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/project_settings.tsx
@@ -0,0 +1,150 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiOverlayMask,
+ EuiSwitch,
+ EuiText,
+ EuiTitle,
+} from '@elastic/eui';
+import React, { ChangeEvent } from 'react';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+import { LanguageServer } from '../../../common/language_server';
+import { RepositoryUtils } from '../../../common/repository_utils';
+import { RepositoryConfig } from '../../../model';
+import { RepoConfigPayload, switchLanguageServer } from '../../actions';
+import { RootState } from '../../reducers';
+import { JavaIcon, TypeScriptIcon } from '../shared/icons';
+
+const defaultConfig = {
+ disableGo: true,
+ disableJava: true,
+ disableTypescript: true,
+};
+
+interface StateProps {
+ languageServers: LanguageServer[];
+ config: RepositoryConfig;
+}
+
+interface DispatchProps {
+ switchLanguageServer: (p: RepoConfigPayload) => void;
+}
+
+interface OwnProps {
+ repoUri: string;
+ url: string;
+ onClose: () => void;
+}
+
+interface State {
+ config: RepositoryConfig;
+}
+
+class ProjectSettingsModal extends React.PureComponent<
+ StateProps & DispatchProps & OwnProps,
+ State
+> {
+ public state = {
+ config: this.props.config,
+ };
+
+ public onSwitchChange = (ls: string) => (e: ChangeEvent) => {
+ const { checked } = e.target;
+ this.setState((prevState: State) => ({
+ config: { ...prevState.config, [`disable${ls}`]: !checked },
+ }));
+ };
+
+ public saveChanges = () => {
+ this.props.switchLanguageServer({
+ repoUri: this.props.repoUri,
+ config: this.state.config,
+ });
+ };
+
+ public render() {
+ const { repoUri, languageServers, onClose } = this.props;
+ const { disableJava, disableTypescript } = this.state.config;
+ const org = RepositoryUtils.orgNameFromUri(repoUri);
+ const repoName = RepositoryUtils.repoNameFromUri(repoUri);
+ const languageServerSwitches = languageServers.map(ls => {
+ const checked = ls.name === 'Java' ? !disableJava : !disableTypescript;
+ return (
+
+
+ {ls.name === 'Java' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ {ls.name}
+
+ }
+ checked={checked}
+ onChange={this.onSwitchChange(ls.name)}
+ />
+
+ );
+ });
+ return (
+
+
+
+
+ Project Settings
+
+ {org}/{repoName}
+
+
+
+
+
+ Language Servers
+
+ {languageServerSwitches}
+
+
+
+ Manage Language Servers
+
+ Save Changes
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState, ownProps: { repoUri: string }) => ({
+ languageServers: state.languageServer.languageServers,
+ config: state.repository.projectConfigs![ownProps.repoUri] || defaultConfig,
+});
+
+const mapDispatchToProps = {
+ switchLanguageServer,
+};
+
+export const ProjectSettings = connect(
+ // @ts-ignore
+ mapStateToProps,
+ mapDispatchToProps
+)(ProjectSettingsModal);
diff --git a/x-pack/plugins/code/public/components/admin_page/project_tab.tsx b/x-pack/plugins/code/public/components/admin_page/project_tab.tsx
new file mode 100644
index 0000000000000..e108e73449ed6
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/project_tab.tsx
@@ -0,0 +1,286 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiForm,
+ EuiFormRow,
+ EuiGlobalToastList,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiOverlayMask,
+ EuiSpacer,
+ // @ts-ignore
+ EuiSuperSelect,
+ EuiText,
+ EuiTitle,
+} from '@elastic/eui';
+import moment from 'moment';
+import React, { ChangeEvent } from 'react';
+import { connect } from 'react-redux';
+import { capabilities } from 'ui/capabilities';
+import { Repository } from '../../../model';
+import { closeToast, importRepo } from '../../actions';
+import { RepoStatus, RootState } from '../../reducers';
+import { ToastType } from '../../reducers/repository';
+import { isImportRepositoryURLInvalid } from '../../utils/url';
+import { ProjectItem } from './project_item';
+import { ProjectSettings } from './project_settings';
+
+enum SortOptionsValue {
+ AlphabeticalAsc = 'alphabetical_asc',
+ AlphabeticalDesc = 'alphabetical_desc',
+ UpdatedAsc = 'updated_asc',
+ UpdatedDesc = 'updated_desc',
+ RecentlyAdded = 'recently_added',
+}
+
+const sortFunctionsFactory = (status: { [key: string]: RepoStatus }) => {
+ const sortFunctions: { [k: string]: (a: Repository, b: Repository) => number } = {
+ [SortOptionsValue.AlphabeticalAsc]: (a: Repository, b: Repository) =>
+ a.name!.localeCompare(b.name!),
+ [SortOptionsValue.AlphabeticalDesc]: (a: Repository, b: Repository) =>
+ b.name!.localeCompare(a.name!),
+ [SortOptionsValue.UpdatedAsc]: (a: Repository, b: Repository) =>
+ moment(status[b.uri].timestamp).diff(moment(status[a.uri].timestamp)),
+ [SortOptionsValue.UpdatedDesc]: (a: Repository, b: Repository) =>
+ moment(status[a.uri].timestamp).diff(moment(status[b.uri].timestamp)),
+ [SortOptionsValue.RecentlyAdded]: () => {
+ return -1;
+ },
+ };
+ return sortFunctions;
+};
+
+const sortOptions = [
+ { value: SortOptionsValue.AlphabeticalAsc, inputDisplay: 'A to Z' },
+ { value: SortOptionsValue.AlphabeticalDesc, inputDisplay: 'Z to A' },
+ { value: SortOptionsValue.UpdatedAsc, inputDisplay: 'Last Updated ASC' },
+ { value: SortOptionsValue.UpdatedDesc, inputDisplay: 'Last Updated DESC' },
+ // { value: SortOptionsValue.recently_added, inputDisplay: 'Recently Added' },
+];
+
+interface Props {
+ projects: Repository[];
+ status: { [key: string]: RepoStatus };
+ importRepo: (repoUrl: string) => void;
+ importLoading: boolean;
+ toastMessage?: string;
+ showToast: boolean;
+ toastType?: ToastType;
+ closeToast: () => void;
+}
+interface State {
+ showImportProjectModal: boolean;
+ importLoading: boolean;
+ settingModal: { url?: string; uri?: string; show: boolean };
+ repoURL: string;
+ isInvalid: boolean;
+ sortOption: SortOptionsValue;
+}
+
+class CodeProjectTab extends React.PureComponent {
+ public static getDerivedStateFromProps(props: Readonly, state: State) {
+ if (state.importLoading && !props.importLoading) {
+ return { showImportProjectModal: false, importLoading: props.importLoading, repoURL: '' };
+ }
+ return { importLoading: props.importLoading };
+ }
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ importLoading: false,
+ showImportProjectModal: false,
+ settingModal: { show: false },
+ repoURL: '',
+ sortOption: SortOptionsValue.AlphabeticalAsc,
+ isInvalid: false,
+ };
+ }
+
+ public closeModal = () => {
+ this.setState({ showImportProjectModal: false, repoURL: '', isInvalid: false });
+ };
+
+ public openModal = () => {
+ this.setState({ showImportProjectModal: true });
+ };
+
+ public openSettingModal = (uri: string, url: string) => {
+ this.setState({ settingModal: { uri, url, show: true } });
+ };
+
+ public closeSettingModal = () => {
+ this.setState({ settingModal: { show: false } });
+ };
+
+ public onChange = (e: ChangeEvent) => {
+ this.setState({
+ repoURL: e.target.value,
+ isInvalid: isImportRepositoryURLInvalid(e.target.value),
+ });
+ };
+
+ public submitImportProject = () => {
+ if (!isImportRepositoryURLInvalid(this.state.repoURL)) {
+ this.props.importRepo(this.state.repoURL);
+ } else if (!this.state.isInvalid) {
+ this.setState({ isInvalid: true });
+ }
+ };
+
+ public updateIsInvalid = () => {
+ this.setState({ isInvalid: isImportRepositoryURLInvalid(this.state.repoURL) });
+ };
+
+ public renderImportModal = () => {
+ return (
+
+
+
+ Add new project
+
+
+
+ Repository URL
+
+
+
+
+
+
+
+
+ Cancel
+
+ Import project
+
+
+
+
+ );
+ };
+
+ public setSortOption = (value: string) => {
+ this.setState({ sortOption: value as SortOptionsValue });
+ };
+
+ public render() {
+ const { projects, status, toastMessage, showToast, toastType } = this.props;
+ const projectsCount = projects.length;
+ const modal = this.state.showImportProjectModal && this.renderImportModal();
+
+ const sortedProjects = projects.sort(sortFunctionsFactory(status)[this.state.sortOption]);
+
+ const repoList = sortedProjects.map((repo: Repository) => (
+
+ ));
+
+ let settings = null;
+ if (this.state.settingModal.show) {
+ settings = (
+
+ );
+ }
+
+ return (
+
+ {showToast && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {(capabilities.get().code.admin as boolean) && (
+ // @ts-ignore
+
+ Add New Project
+
+ )}
+
+
+
+
+
+ {projectsCount}
+ {projectsCount === 1 ? Project : Projects }
+
+
+
+ {repoList}
+ {modal}
+ {settings}
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ projects: state.repository.repositories,
+ status: state.status.status,
+ importLoading: state.repository.importLoading,
+ toastMessage: state.repository.toastMessage,
+ toastType: state.repository.toastType,
+ showToast: state.repository.showToast,
+});
+
+const mapDispatchToProps = {
+ importRepo,
+ closeToast,
+};
+
+export const ProjectTab = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(CodeProjectTab);
diff --git a/x-pack/plugins/code/public/components/admin_page/setup_guide.tsx b/x-pack/plugins/code/public/components/admin_page/setup_guide.tsx
new file mode 100644
index 0000000000000..dee813b684e22
--- /dev/null
+++ b/x-pack/plugins/code/public/components/admin_page/setup_guide.tsx
@@ -0,0 +1,169 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiCallOut,
+ EuiGlobalToastList,
+ EuiPanel,
+ EuiSpacer,
+ EuiSteps,
+ EuiText,
+ EuiTitle,
+} from '@elastic/eui';
+import React from 'react';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+import { documentationLinks } from '../../lib/documentation_links';
+import { RootState } from '../../reducers';
+
+const steps = [
+ {
+ title: 'Configure Kibana Code Instance for Multiple Kibana Nodes',
+ children: (
+
+
+ If you are using multiple Kibana nodes, then you need to configure 1 Kibana instance as
+ Code instance. Please add the following line of code into your kibana.yml file for every
+ instance to indicate your Code instance:
+
+
+ xpack.code.codeNodeUrl: 'http://$YourCodeNodeAddress'
+
+ Then, restart every Kibana instance.
+
+ ),
+ },
+ {
+ title: 'Download and install language servers',
+ children: (
+
+
+ If you need code intelligence support for your repos, you need to install the language
+ server for the programming languages.
+
+
+ PRE-INSTALLED LANGUAGE SERVERS:
+
+ Typescript
+
+ AVAILABLE LANGUAGE SERVERS:
+
+ Java
+
+ Manage language server installation
+
+ ),
+ },
+ {
+ title: 'Import a repository from a git address',
+ children: (
+
+
+ You can add a repo to Code by simply putting in the git address of the repo. Usually this
+ is the same git address you use to run the git clone command, you can find more details
+ about the formats of git addresses that Code accepts
+ here .
+
+
+ ),
+ },
+ {
+ title: 'Verify that your repo has been successfully imported',
+ children: (
+
+
+ Once the repo is added and indexed successfully, you can verify that the repo is
+ searchable and the code intelligence is available. You can find more details of how the
+ search and code intelligence work in{' '}
+ our docs .
+
+
+ ),
+ },
+];
+
+// TODO add link to learn more button
+const toastMessage = (
+
+
+ We’ve made some changes to roles and permissions in Kibana. Read more about what these changes
+ mean for you below.{' '}
+
+
+ Learn More
+
+
+);
+
+class SetupGuidePage extends React.PureComponent<{ setupOk?: boolean }, { hideToast: boolean }> {
+ constructor(props: { setupOk?: boolean }) {
+ super(props);
+
+ this.state = {
+ hideToast: false,
+ };
+ }
+
+ public render() {
+ let setup = null;
+ if (this.props.setupOk !== undefined) {
+ setup = (
+
+ {!this.state.hideToast && (
+
{
+ this.setState({ hideToast: true });
+ }}
+ toastLifeTimeMs={10000}
+ />
+ )}
+
+ {this.props.setupOk === false && (
+
+
+ Please follow the guide below to configure your Kibana instance and then refresh
+ this page.
+
+
+ )}
+ {this.props.setupOk === true && (
+
+
+
+ Back To Project Dashboard
+
+
+ )}
+
+
+ Getting started in Elastic Code
+
+
+
+
+
+
+ );
+ }
+ return {setup}
;
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ setupOk: state.setup.ok,
+});
+
+export const SetupGuide = connect(mapStateToProps)(SetupGuidePage);
diff --git a/x-pack/plugins/code/public/components/app.tsx b/x-pack/plugins/code/public/components/app.tsx
new file mode 100644
index 0000000000000..a000442629ec5
--- /dev/null
+++ b/x-pack/plugins/code/public/components/app.tsx
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { HashRouter as Router, Redirect, Switch } from 'react-router-dom';
+
+import { connect } from 'react-redux';
+import { RootState } from '../reducers';
+import { Admin } from './admin_page/admin';
+import { SetupGuide } from './admin_page/setup_guide';
+import { Diff } from './diff_page/diff';
+import { Main } from './main/main';
+import { NotFound } from './main/not_found';
+import { Route } from './route';
+import * as ROUTES from './routes';
+import { Search } from './search_page/search';
+
+const Empty = () => null;
+
+const RooComponent = (props: { setupOk?: boolean }) => {
+ if (props.setupOk) {
+ return ;
+ }
+ return ;
+};
+
+const mapStateToProps = (state: RootState) => ({
+ setupOk: state.setup.ok,
+});
+
+const Root = connect(mapStateToProps)(RooComponent);
+
+export const App = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/code/public/components/codeblock/codeblock.tsx b/x-pack/plugins/code/public/components/codeblock/codeblock.tsx
new file mode 100644
index 0000000000000..aa4a4491f56fd
--- /dev/null
+++ b/x-pack/plugins/code/public/components/codeblock/codeblock.tsx
@@ -0,0 +1,139 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiPanel } from '@elastic/eui';
+import { editor, IPosition, IRange } from 'monaco-editor';
+import React from 'react';
+import { ResizeChecker } from 'ui/resize_checker';
+import { monaco } from '../../monaco/monaco';
+import { registerEditor } from '../../monaco/single_selection_helper';
+
+interface Props {
+ code: string;
+ fileComponent?: React.ReactNode;
+ startLine?: number;
+ language?: string;
+ highlightRanges?: IRange[];
+ onClick?: (event: IPosition) => void;
+ folding: boolean;
+ lineNumbersFunc: (line: number) => string;
+}
+
+export class CodeBlock extends React.PureComponent {
+ private el: HTMLDivElement | null = null;
+ private ed?: editor.IStandaloneCodeEditor;
+ private resizeChecker?: ResizeChecker;
+ private currentHighlightDecorations: string[] = [];
+
+ public componentDidMount(): void {
+ if (this.el) {
+ this.ed = monaco.editor.create(this.el!, {
+ value: this.props.code,
+ language: this.props.language,
+ lineNumbers: this.lineNumbersFunc.bind(this),
+ readOnly: true,
+ folding: this.props.folding,
+ minimap: {
+ enabled: false,
+ },
+ scrollbar: {
+ vertical: 'hidden',
+ handleMouseWheel: false,
+ verticalScrollbarSize: 0,
+ },
+ hover: {
+ enabled: false, // disable default hover;
+ },
+ contextmenu: false,
+ selectOnLineNumbers: false,
+ selectionHighlight: false,
+ renderLineHighlight: 'none',
+ renderIndentGuides: false,
+ automaticLayout: false,
+ });
+ this.ed.onMouseDown((e: editor.IEditorMouseEvent) => {
+ if (
+ this.props.onClick &&
+ (e.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS ||
+ e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT)
+ ) {
+ const lineNumber = (this.props.startLine || 0) + e.target.position.lineNumber;
+ this.props.onClick({
+ lineNumber,
+ column: e.target.position.column,
+ });
+ }
+ });
+ registerEditor(this.ed);
+ if (this.props.highlightRanges) {
+ const decorations = this.props.highlightRanges.map((range: IRange) => {
+ return {
+ range,
+ options: {
+ inlineClassName: 'codeSearch__highlight',
+ },
+ };
+ });
+ this.currentHighlightDecorations = this.ed.deltaDecorations([], decorations);
+ }
+ this.resizeChecker = new ResizeChecker(this.el!);
+ this.resizeChecker.on('resize', () => {
+ setTimeout(() => {
+ this.ed!.layout();
+ });
+ });
+ }
+ }
+
+ public componentDidUpdate(prevProps: Readonly) {
+ if (
+ prevProps.code !== this.props.code ||
+ prevProps.highlightRanges !== this.props.highlightRanges
+ ) {
+ if (this.ed) {
+ this.ed.getModel().setValue(this.props.code);
+
+ if (this.props.highlightRanges) {
+ const decorations = this.props.highlightRanges!.map((range: IRange) => {
+ return {
+ range,
+ options: {
+ inlineClassName: 'codeSearch__highlight',
+ },
+ };
+ });
+ this.currentHighlightDecorations = this.ed.deltaDecorations(
+ this.currentHighlightDecorations,
+ decorations
+ );
+ }
+ }
+ }
+ }
+
+ public componentWillUnmount(): void {
+ if (this.ed) {
+ this.ed.dispose();
+ }
+ }
+
+ public render() {
+ const linesCount = this.props.code.split('\n').length;
+ return (
+
+ {this.props.fileComponent}
+ (this.el = r)} style={{ height: linesCount * 18 }} />
+
+ );
+ }
+
+ private lineNumbersFunc = (line: number) => {
+ if (this.props.lineNumbersFunc) {
+ return this.props.lineNumbersFunc(line);
+ }
+ return `${(this.props.startLine || 0) + line}`;
+ };
+}
diff --git a/x-pack/plugins/code/public/components/diff_page/commit_link.tsx b/x-pack/plugins/code/public/components/diff_page/commit_link.tsx
new file mode 100644
index 0000000000000..c2e79a5fd7490
--- /dev/null
+++ b/x-pack/plugins/code/public/components/diff_page/commit_link.tsx
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { EuiBadge /* , EuiLink*/ } from '@elastic/eui';
+import React from 'react';
+// import { DIFF } from '../routes';
+
+interface Props {
+ repoUri: string;
+ commit: string;
+ children?: any;
+}
+
+export const CommitLink = ({ repoUri, commit, children }: Props) => {
+ // const href = DIFF.replace(':resource/:org/:repo', repoUri).replace(':commitId', commit);
+ return (
+ //
+ {children || commit}
+ //
+ );
+};
diff --git a/x-pack/plugins/code/public/components/diff_page/diff.scss b/x-pack/plugins/code/public/components/diff_page/diff.scss
new file mode 100644
index 0000000000000..c402660599f57
--- /dev/null
+++ b/x-pack/plugins/code/public/components/diff_page/diff.scss
@@ -0,0 +1,16 @@
+
+
+.diff > button.euiAccordion__button > div:first-child {
+ flex-direction: row-reverse;
+ padding: $euiSize $euiSizeS;
+}
+
+.diff > button.euiAccordion__button {
+ &:hover {
+ text-decoration: none;
+ }
+}
+
+.euiAccordion__iconWrapper {
+ cursor: pointer;
+}
diff --git a/x-pack/plugins/code/public/components/diff_page/diff.tsx b/x-pack/plugins/code/public/components/diff_page/diff.tsx
new file mode 100644
index 0000000000000..4492f370c1ed6
--- /dev/null
+++ b/x-pack/plugins/code/public/components/diff_page/diff.tsx
@@ -0,0 +1,246 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle } from '@elastic/eui';
+import theme from '@elastic/eui/dist/eui_theme_light.json';
+import React, { MouseEvent } from 'react';
+import { connect } from 'react-redux';
+import { Link, RouteComponentProps, withRouter } from 'react-router-dom';
+import styled from 'styled-components';
+import { CommitDiff, FileDiff } from '../../../common/git_diff';
+import { SearchScope } from '../../../model';
+import { changeSearchScope } from '../../actions';
+import { RootState } from '../../reducers';
+import { SearchBar } from '../search_page/search_bar';
+import { ShortcutsProvider } from '../shortcuts';
+import { DiffEditor } from './diff_editor';
+
+const COMMIT_ID_LENGTH = 16;
+
+const B = styled.b`
+ font-weight: bold;
+`;
+
+const PrimaryB = styled(B)`
+ color: ${theme.euiColorPrimary};
+`;
+
+const CommitId = styled.span`
+ display: inline-block;
+ padding: 0 ${theme.paddingSizes.xs};
+ border: ${theme.euiBorderThin};
+`;
+
+const Addition = styled.div`
+ padding: ${theme.paddingSizes.xs} ${theme.paddingSizes.s};
+ border-radius: ${theme.euiSizeXS};
+ color: white;
+ margin-right: ${theme.euiSizeS};
+ background-color: ${theme.euiColorDanger};
+`;
+
+const Deletion = styled(Addition)`
+ background-color: ${theme.euiColorVis0};
+`;
+
+const Container = styled.div`
+ padding: ${theme.paddingSizes.xs} ${theme.paddingSizes.m};
+`;
+
+const TopBarContainer = styled.div`
+ height: calc(48rem / 14);
+ border-bottom: ${theme.euiBorderThin};
+ padding: 0 ${theme.paddingSizes.m};
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+`;
+
+const Accordion = styled(EuiAccordion)`
+ border: ${theme.euiBorderThick};
+ border-radius: ${theme.euiSizeS};
+ margin-bottom: ${theme.euiSize};
+`;
+
+const Icon = styled(EuiIcon)`
+ margin-right: ${theme.euiSizeS};
+`;
+
+const Parents = styled.div`
+ border-left: ${theme.euiBorderThin};
+ height: calc(32rem / 14);
+ line-height: calc(32rem / 14);
+ padding-left: ${theme.paddingSizes.s};
+ margin: ${theme.euiSizeS} 0;
+`;
+
+const H4 = styled.h4`
+ height: 100%;
+ line-height: calc(48rem / 14);
+`;
+
+const ButtonContainer = styled.div`
+ cursor: default;
+`;
+
+interface Props extends RouteComponentProps<{ resource: string; org: string; repo: string }> {
+ commit: CommitDiff | null;
+ query: string;
+ onSearchScopeChanged: (s: SearchScope) => void;
+ repoScope: string[];
+}
+
+export enum DiffLayout {
+ Unified,
+ Split,
+}
+
+const onClick = (e: MouseEvent
) => {
+ e.preventDefault();
+ e.stopPropagation();
+};
+
+const Difference = (props: { fileDiff: FileDiff; repoUri: string; revision: string }) => (
+
+
+
+
+ {props.fileDiff.additions}
+ {props.fileDiff.deletions}
+
+
+ {props.fileDiff.path}
+
+
+
+
+ View File
+
+
+
+
+
+
+ }
+ >
+
+
+);
+
+export class DiffPage extends React.Component {
+ public state = {
+ diffLayout: DiffLayout.Split,
+ };
+
+ public setLayoutUnified = () => {
+ this.setState({ diffLayout: DiffLayout.Unified });
+ };
+
+ public setLayoutSplit = () => {
+ this.setState({ diffLayout: DiffLayout.Split });
+ };
+
+ public render() {
+ const { commit, match } = this.props;
+ const { repo, org, resource } = match.params;
+ const repoUri = `${resource}/${org}/${repo}`;
+ if (!commit) {
+ return null;
+ }
+ const { additions, deletions, files } = commit;
+ const { parents } = commit.commit;
+ const title = commit.commit.message.split('\n')[0];
+ let parentsLinks = null;
+ if (parents.length > 1) {
+ const [p1, p2] = parents;
+ parentsLinks = (
+
+ {p1}+
+ {p2}
+
+ );
+ } else if (parents.length === 1) {
+ parentsLinks = {parents[0]};
+ }
+ const topBar = (
+
+
+
+ {title}
+
+
+
+
Parents: {parentsLinks}
+
+
+ );
+ const fileCount = files.length;
+ const diffs = commit.files.map(file => (
+
+ ));
+ return (
+
+
+ {topBar}
+
+ {commit.commit.message}
+
+
+
+
+
+
+ Showing
+ {fileCount} Changed files
+ with
+ {additions} additions and {deletions} deletions
+
+
+
+
+ Committed by
+ {commit.commit.committer}
+ {commit.commit.id.substr(0, COMMIT_ID_LENGTH)}
+
+
+
+
+
{diffs}
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ commit: state.commit.commit,
+ query: state.search.query,
+ repoScope: state.search.searchOptions.repoScope.map(r => r.uri),
+});
+
+const mapDispatchToProps = {
+ onSearchScopeChanged: changeSearchScope,
+};
+
+export const Diff = withRouter(
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )(DiffPage)
+);
diff --git a/x-pack/plugins/code/public/components/diff_page/diff_editor.tsx b/x-pack/plugins/code/public/components/diff_page/diff_editor.tsx
new file mode 100644
index 0000000000000..01b77d250b161
--- /dev/null
+++ b/x-pack/plugins/code/public/components/diff_page/diff_editor.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor } from 'monaco-editor';
+import React from 'react';
+import { MonacoDiffEditor } from '../../monaco/monaco_diff_editor';
+
+interface Props {
+ originCode: string;
+ modifiedCode: string;
+ language: string;
+ renderSideBySide: boolean;
+}
+
+export class DiffEditor extends React.Component {
+ private diffEditor: MonacoDiffEditor | null = null;
+ public mountDiffEditor = (container: HTMLDivElement) => {
+ this.diffEditor = new MonacoDiffEditor(
+ container,
+ this.props.originCode,
+ this.props.modifiedCode,
+ this.props.language,
+ this.props.renderSideBySide
+ );
+ this.diffEditor.init();
+ };
+
+ public componentDidUpdate(prevProps: Props) {
+ if (prevProps.renderSideBySide !== this.props.renderSideBySide) {
+ this.updateLayout(this.props.renderSideBySide);
+ }
+ }
+
+ public updateLayout(renderSideBySide: boolean) {
+ this.diffEditor!.diffEditor!.updateOptions({ renderSideBySide } as editor.IDiffEditorOptions);
+ }
+
+ public render() {
+ return
;
+ }
+}
diff --git a/x-pack/plugins/code/public/components/editor/editor.tsx b/x-pack/plugins/code/public/components/editor/editor.tsx
new file mode 100644
index 0000000000000..bf30230f98e60
--- /dev/null
+++ b/x-pack/plugins/code/public/components/editor/editor.tsx
@@ -0,0 +1,236 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexItem } from '@elastic/eui';
+import { editor as editorInterfaces } from 'monaco-editor';
+import React from 'react';
+import { connect } from 'react-redux';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import { Hover, Position, TextDocumentPositionParams } from 'vscode-languageserver-protocol';
+import { GitBlame } from '../../../common/git_blame';
+import { closeReferences, FetchFileResponse, findReferences, hoverResult } from '../../actions';
+import { MainRouteParams } from '../../common/types';
+import { BlameWidget } from '../../monaco/blame/blame_widget';
+import { monaco } from '../../monaco/monaco';
+import { MonacoHelper } from '../../monaco/monaco_helper';
+import { RootState } from '../../reducers';
+import { refUrlSelector } from '../../selectors';
+import { history } from '../../utils/url';
+import { Modifier, Shortcut } from '../shortcuts';
+import { ReferencesPanel } from './references_panel';
+import { encodeRevisionString } from '../../utils/url';
+
+export interface EditorActions {
+ closeReferences(changeUrl: boolean): void;
+ findReferences(params: TextDocumentPositionParams): void;
+ hoverResult(hover: Hover): void;
+}
+
+interface Props {
+ file: FetchFileResponse;
+ revealPosition?: Position;
+ isReferencesOpen: boolean;
+ isReferencesLoading: boolean;
+ references: any[];
+ referencesTitle: string;
+ hover?: Hover;
+ refUrl?: string;
+ blames: GitBlame[];
+ showBlame: boolean;
+}
+
+type IProps = Props & EditorActions & RouteComponentProps;
+
+export class EditorComponent extends React.Component {
+ public blameWidgets: any;
+ private container: HTMLElement | undefined;
+ private monaco: MonacoHelper | undefined;
+ private editor: editorInterfaces.IStandaloneCodeEditor | undefined;
+ private lineDecorations: string[] | null = null;
+
+ constructor(props: IProps, context: any) {
+ super(props, context);
+ }
+
+ public componentDidMount(): void {
+ this.container = document.getElementById('mainEditor') as HTMLElement;
+ this.monaco = new MonacoHelper(this.container, this.props);
+
+ const { file } = this.props;
+ if (file && file.content) {
+ const { uri, path, revision } = file.payload;
+ const qs = this.props.location.search;
+ this.loadText(file.content, uri, path, file.lang!, revision, qs).then(() => {
+ if (this.props.revealPosition) {
+ this.revealPosition(this.props.revealPosition);
+ }
+ if (this.props.showBlame) {
+ this.loadBlame(this.props.blames);
+ }
+ });
+ }
+ }
+
+ public componentDidUpdate(prevProps: IProps) {
+ const { file } = this.props;
+ const { uri, path, revision } = file.payload;
+ const {
+ resource,
+ org,
+ repo,
+ revision: routeRevision,
+ path: routePath,
+ } = this.props.match.params;
+ const prevContent = prevProps.file && prevProps.file.content;
+ const qs = this.props.location.search;
+ if (prevContent !== file.content || qs !== prevProps.location.search) {
+ this.loadText(file.content!, uri, path, file.lang!, revision, qs).then(() => {
+ if (this.props.revealPosition) {
+ this.revealPosition(this.props.revealPosition);
+ }
+ });
+ } else if (
+ file.payload.uri === `${resource}/${org}/${repo}` &&
+ file.payload.revision === routeRevision &&
+ file.payload.path === routePath &&
+ prevProps.revealPosition !== this.props.revealPosition
+ ) {
+ this.revealPosition(this.props.revealPosition);
+ }
+ if (this.monaco && this.monaco.editor) {
+ if (prevProps.showBlame !== this.props.showBlame && this.props.showBlame) {
+ this.loadBlame(this.props.blames);
+ this.monaco.editor.updateOptions({ lineHeight: 38 });
+ } else if (!this.props.showBlame) {
+ this.destroyBlameWidgets();
+ this.monaco.editor.updateOptions({ lineHeight: 18, lineDecorationsWidth: 16 });
+ }
+ if (prevProps.blames !== this.props.blames && this.props.showBlame) {
+ this.loadBlame(this.props.blames);
+ this.monaco.editor.updateOptions({ lineHeight: 38, lineDecorationsWidth: 316 });
+ }
+ }
+ }
+
+ public componentWillUnmount() {
+ this.monaco!.destroy();
+ }
+ public render() {
+ return (
+
+
+
+ {this.renderReferences()}
+
+ );
+ }
+
+ public loadBlame(blames: GitBlame[]) {
+ if (this.blameWidgets) {
+ this.destroyBlameWidgets();
+ }
+ this.blameWidgets = blames.map((b, index) => {
+ return new BlameWidget(b, index === 0, this.monaco!.editor!);
+ });
+ if (!this.lineDecorations) {
+ this.lineDecorations = this.monaco!.editor!.deltaDecorations(
+ [],
+ [
+ {
+ range: new monaco.Range(1, 1, Infinity, 1),
+ options: { isWholeLine: true, linesDecorationsClassName: 'code-line-decoration' },
+ },
+ ]
+ );
+ }
+ }
+
+ public destroyBlameWidgets() {
+ if (this.blameWidgets) {
+ this.blameWidgets.forEach((bw: BlameWidget) => bw.destroy());
+ }
+ if (this.lineDecorations) {
+ this.monaco!.editor!.deltaDecorations(this.lineDecorations!, []);
+ this.lineDecorations = null;
+ }
+ this.blameWidgets = null;
+ }
+
+ private async loadText(
+ text: string,
+ repo: string,
+ file: string,
+ lang: string,
+ revision: string,
+ qs: string
+ ) {
+ if (this.monaco) {
+ this.editor = await this.monaco.loadFile(repo, file, text, lang, revision);
+ this.editor.onMouseDown((e: editorInterfaces.IEditorMouseEvent) => {
+ if (e.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS) {
+ const uri = `${repo}/blob/${encodeRevisionString(revision)}/${file}`;
+ history.push(`/${uri}!L${e.target.position.lineNumber}:0${qs}`);
+ }
+ this.monaco!.container.focus();
+ });
+ }
+ }
+
+ private revealPosition(pos: Position | undefined) {
+ if (this.monaco) {
+ if (pos) {
+ this.monaco.revealPosition(pos.line, pos.character);
+ } else {
+ this.monaco.clearLineSelection();
+ }
+ }
+ }
+
+ private renderReferences() {
+ return (
+ this.props.isReferencesOpen && (
+ this.props.closeReferences(true)}
+ references={this.props.references}
+ isLoading={this.props.isReferencesLoading}
+ title={this.props.referencesTitle}
+ refUrl={this.props.refUrl}
+ />
+ )
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ file: state.file.file,
+ isReferencesOpen: state.editor.showing,
+ isReferencesLoading: state.editor.loading,
+ references: state.editor.references,
+ referencesTitle: state.editor.referencesTitle,
+ hover: state.editor.hover,
+ refUrl: refUrlSelector(state),
+ revealPosition: state.editor.revealPosition,
+ blames: state.blame.blames,
+});
+
+const mapDispatchToProps = {
+ closeReferences,
+ findReferences,
+ hoverResult,
+};
+
+export const Editor = withRouter(
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )(EditorComponent)
+);
diff --git a/x-pack/plugins/code/public/components/editor/references_panel.scss b/x-pack/plugins/code/public/components/editor/references_panel.scss
new file mode 100644
index 0000000000000..fc3df8bb79a45
--- /dev/null
+++ b/x-pack/plugins/code/public/components/editor/references_panel.scss
@@ -0,0 +1,33 @@
+.code-editor-references-panel {
+ position: relative;
+ max-height: 50vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.05), 0px -4px 4px rgba(0, 0, 0, 0.03),
+ 0px -6px 12px rgba(0, 0, 0, 0.05), 0px -12px 24px rgba(0, 0, 0, 0.05);
+}
+
+.code-editor-references-panel.expanded {
+ position: relative;
+ flex-grow: 10;
+ max-height: 95%;
+ height: 95%;
+}
+
+.code-editor-reference-accordion-button {
+ font-size: 13px;
+}
+
+.expandButton {
+ position: absolute;
+ top: -1 * $euiSize;
+ right: $euiSize + 1px;
+ background: $euiColorLightestShade;
+ border: $euiBorderThin;
+ border-bottom: 0;
+ height: 0;
+ min-height: $euiSize;
+ padding: 0;
+ border-radius: $euiSizeXS $euiSizeXS 0 0;
+}
+
diff --git a/x-pack/plugins/code/public/components/editor/references_panel.tsx b/x-pack/plugins/code/public/components/editor/references_panel.tsx
new file mode 100644
index 0000000000000..fa6635c543378
--- /dev/null
+++ b/x-pack/plugins/code/public/components/editor/references_panel.tsx
@@ -0,0 +1,163 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiAccordion,
+ EuiButtonIcon,
+ EuiLoadingKibana,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+ EuiTitle,
+} from '@elastic/eui';
+import classname from 'classnames';
+import { IPosition } from 'monaco-editor';
+import queryString from 'querystring';
+import React from 'react';
+import { parseSchema } from '../../../common/uri_util';
+import { GroupedFileReferences, GroupedRepoReferences } from '../../actions';
+import { history } from '../../utils/url';
+import { CodeBlock } from '../codeblock/codeblock';
+
+interface Props {
+ isLoading: boolean;
+ title: string;
+ references: GroupedRepoReferences[];
+ refUrl?: string;
+ onClose(): void;
+}
+interface State {
+ expanded: boolean;
+}
+
+export class ReferencesPanel extends React.Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ expanded: false,
+ };
+ }
+
+ public close = () => {
+ this.props.onClose();
+ };
+
+ public toggleExpand = () => {
+ this.setState({ expanded: !this.state.expanded });
+ };
+
+ public render() {
+ const body = this.props.isLoading ? : this.renderGroupByRepo();
+ const styles: any = {};
+ const expanded = this.state.expanded;
+ return (
+
+
+ {!expanded && (
+
+ )}
+
+ {this.props.title}
+
+
+ {body}
+
+ );
+ }
+
+ private renderGroupByRepo() {
+ return this.props.references.map((ref: GroupedRepoReferences) => {
+ return this.renderReferenceRepo(ref);
+ });
+ }
+
+ private renderReferenceRepo({ repo, files }: GroupedRepoReferences) {
+ const [org, name] = repo.split('/').slice(1);
+ const buttonContent = (
+
+ {org} /{name}
+
+ );
+
+ return (
+
+ {files.map(file => this.renderReference(file))}
+
+ );
+ }
+
+ private renderReference(file: GroupedFileReferences) {
+ const key = `${file.uri}`;
+ const lineNumberFn = (l: number) => {
+ return file.lineNumbers[l - 1];
+ };
+ const fileComponent = (
+
+
+ {file.file}
+
+
+
+ );
+
+ return (
+
+ );
+ }
+
+ private onCodeClick(lineNumbers: string[], url: string, pos: IPosition) {
+ const line = parseInt(lineNumbers[pos.lineNumber - 1], 10);
+ history.push(this.computeUrl(url, line));
+ }
+
+ private computeUrl(url: string, line?: number) {
+ const { uri } = parseSchema(url)!;
+ let search = history.location.search;
+ if (search.startsWith('?')) {
+ search = search.substring(1);
+ }
+ const queries = queryString.parse(search);
+ const query = queryString.stringify({
+ ...queries,
+ tab: 'references',
+ refUrl: this.props.refUrl,
+ });
+ return line !== undefined ? `${uri}!L${line}:0?${query}` : `${uri}?${query}`;
+ }
+}
diff --git a/x-pack/plugins/code/public/components/file_tree/__fixtures__/props.json b/x-pack/plugins/code/public/components/file_tree/__fixtures__/props.json
new file mode 100644
index 0000000000000..f6cd6cbd4e9aa
--- /dev/null
+++ b/x-pack/plugins/code/public/components/file_tree/__fixtures__/props.json
@@ -0,0 +1,203 @@
+{
+ "node":{
+ "name":"",
+ "path":"",
+ "type":1,
+ "childrenCount":19,
+ "children":[
+ {
+ "name":"android",
+ "path":"android",
+ "sha1":"a2ea0b08e5d3c02cdfd6648b1e6e930eea95c2f2",
+ "type":1
+ },
+ {
+ "name":"futures",
+ "path":"futures",
+ "sha1":"9d34a7a1aeb1c0a158134897d689b45ee3ba10cc",
+ "type":1
+ },
+ {
+ "name":"guava",
+ "path":"guava",
+ "sha1":"17532aab4ac810a06f0f258bffaff50d55e4ee94",
+ "type":1,
+ "childrenCount":3,
+ "children":[
+ {
+ "name":"javadoc-link",
+ "path":"guava/javadoc-link",
+ "sha1":"c03fe568863a7d437f1f69712583c5381f1225f2",
+ "type":1
+ },
+ {
+ "name":"pom.xml",
+ "path":"guava/pom.xml",
+ "sha1":"845e4b4c9428c26f3403c55eb75ecc2c0f4bb798",
+ "type":0
+ },
+ {
+ "name":"src",
+ "path":"guava/src",
+ "sha1":"c652417027db78632a0458783eb5424eed30ec15",
+ "type":1,
+ "childrenCount":1,
+ "children":[
+ {
+ "name":"com",
+ "path":"guava/src/com",
+ "sha1":"8aef9b1b3385f9480a8ccc5d3f3f3fe9e225bf30",
+ "type":1,
+ "childrenCount":1,
+ "children":[
+ {
+ "name":"google",
+ "path":"guava/src/com/google",
+ "sha1":"8ea38b7a4b545ee5e194cdf018601fb802b08173",
+ "type":1,
+ "childrenCount":2,
+ "children":[
+ {
+ "name":"common",
+ "path":"guava/src/com/google/common",
+ "sha1":"ad31410caf6bd224498690453f7699080a3e0df6",
+ "type":1
+ },
+ {
+ "name":"thirdparty",
+ "path":"guava/src/com/google/thirdparty",
+ "sha1":"935695a8668173410c23e8a4d44871bce3bddb32",
+ "type":1,
+ "childrenCount":1,
+ "children":[
+ {
+ "name":"publicsuffix",
+ "path":"guava/src/com/google/thirdparty/publicsuffix",
+ "sha1":"ca7a68b3c5a0ebe251364d342e1d9309a63bfd65",
+ "type":1
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name":"guava-bom",
+ "path":"guava-bom",
+ "sha1":"506da491b49b1a9db603ffe96ff748dbe6c666cf",
+ "type":1,
+ "childrenCount":1,
+ "children":[
+ {
+ "name":"pom.xml",
+ "path":"guava-bom/pom.xml",
+ "sha1":"d32778eaae10d029fab8129a90d942da166b6e29",
+ "type":0
+ }
+ ]
+ },
+ {
+ "name":"guava-gwt",
+ "path":"guava-gwt",
+ "sha1":"01b81f8d124d4cc4222ec9be4426940dd066dd64",
+ "type":1
+ },
+ {
+ "name":"guava-testlib",
+ "path":"guava-testlib",
+ "sha1":"ba68ef1df869adbc3e2a72372ec96f80d7723d7c",
+ "type":1
+ },
+ {
+ "name":"guava-tests",
+ "path":"guava-tests",
+ "sha1":"c51f5c29a9320903910db537270fbc04039ea3ef",
+ "type":1
+ },
+ {
+ "name":"refactorings",
+ "path":"refactorings",
+ "sha1":"73dc70e964c07e5c4d1dc210e8c95f160fd7e4c1",
+ "type":1
+ },
+ {
+ "name":"util",
+ "path":"util",
+ "sha1":"121ac413ef81b5f3d80d059f024b0d4c2dde31ae",
+ "type":1
+ },
+ {
+ "name":".gitattributes",
+ "path":".gitattributes",
+ "sha1":"1e3b76511d02b52d500387c8d40060a57d109c79",
+ "type":0
+ },
+ {
+ "name":".gitignore",
+ "path":".gitignore",
+ "sha1":"942c3986a9b7a2d5fb5c66f0d404f2665fe89fc0",
+ "type":0
+ },
+ {
+ "name":".travis.yml",
+ "path":".travis.yml",
+ "sha1":"0890618156c2427bb0f75df4b286b8d06e393dd8",
+ "type":0
+ },
+ {
+ "name":"CONTRIBUTING.md",
+ "path":"CONTRIBUTING.md",
+ "sha1":"8acd79c21bb287fe73323cc046ab1157cafb194a",
+ "type":0
+ },
+ {
+ "name":"CONTRIBUTORS",
+ "path":"CONTRIBUTORS",
+ "sha1":"88ecb640d1a2d7af7b1b58657bb0475db0e07d77",
+ "type":0
+ },
+ {
+ "name":"COPYING",
+ "path":"COPYING",
+ "sha1":"d645695673349e3947e8e5ae42332d0ac3164cd7",
+ "type":0
+ },
+ {
+ "name":"README.md",
+ "path":"README.md",
+ "sha1":"dea66d815f15cec63775c536e90cb309af501dec",
+ "type":0
+ },
+ {
+ "name":"cycle_whitelist.txt",
+ "path":"cycle_whitelist.txt",
+ "sha1":"e9c70c3ef79a97f22156f01be7d3768eec8b1405",
+ "type":0
+ },
+ {
+ "name":"javadoc-stylesheet.css",
+ "path":"javadoc-stylesheet.css",
+ "sha1":"64cbb4fbc6ef079832263190e209c27cecad8fff",
+ "type":0
+ },
+ {
+ "name":"pom.xml",
+ "path":"pom.xml",
+ "sha1":"e688d8edf2b4ce5bf847743fee567a34c2f601d2",
+ "type":0
+ }
+ ],
+ "repoUri":"github.com/google/guava"
+ },
+ "openedPaths":[
+ "guava/src/com/google",
+ "guava",
+ "guava/src",
+ "guava/src/com"
+ ]
+}
\ No newline at end of file
diff --git a/x-pack/plugins/code/public/components/file_tree/__snapshots__/file_tree.test.tsx.snap b/x-pack/plugins/code/public/components/file_tree/__snapshots__/file_tree.test.tsx.snap
new file mode 100644
index 0000000000000..ce2b92c95007d
--- /dev/null
+++ b/x-pack/plugins/code/public/components/file_tree/__snapshots__/file_tree.test.tsx.snap
@@ -0,0 +1,1443 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`render correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ className="euiSideNavItemButton"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ futures/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ guava/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ javadoc-link/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src/com/google/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ common/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ thirdparty/publicsuffix/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ guava-bom/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ guava-gwt/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ guava-testlib/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ guava-tests/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ refactorings/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ util/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cycle_whitelist.txt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ javadoc-stylesheet.css
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/code/public/components/file_tree/file_tree.test.tsx b/x-pack/plugins/code/public/components/file_tree/file_tree.test.tsx
new file mode 100644
index 0000000000000..99a08f33de3c0
--- /dev/null
+++ b/x-pack/plugins/code/public/components/file_tree/file_tree.test.tsx
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { History, Location } from 'history';
+import React from 'react';
+import { match } from 'react-router-dom';
+import renderer from 'react-test-renderer';
+import { MainRouteParams, PathTypes } from '../../common/types';
+import { createHistory, createLocation, createMatch, mockFunction } from '../../utils/test_utils';
+import props from './__fixtures__/props.json';
+import { CodeFileTree } from './file_tree';
+
+const location: Location = createLocation({
+ pathname: '/github.com/google/guava/tree/master/guava/src/com/google',
+});
+
+const m: match = createMatch({
+ path: '/:resource/:org/:repo/:pathType(blob|tree)/:revision/:path*:goto(!.*)?',
+ url: '/github.com/google/guava/tree/master/guava/src/com/google',
+ isExact: true,
+ params: {
+ resource: 'github.com',
+ org: 'google',
+ repo: 'guava',
+ pathType: PathTypes.tree,
+ revision: 'master',
+ path: 'guava/src/com/google',
+ },
+});
+
+const history: History = createHistory({ location, length: 8, action: 'POP' });
+
+test('render correctly', () => {
+ const tree = renderer
+ .create(
+
+ )
+ .toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/x-pack/plugins/code/public/components/file_tree/file_tree.tsx b/x-pack/plugins/code/public/components/file_tree/file_tree.tsx
new file mode 100644
index 0000000000000..9edd67fa9243f
--- /dev/null
+++ b/x-pack/plugins/code/public/components/file_tree/file_tree.tsx
@@ -0,0 +1,270 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+import { EuiIcon, EuiSideNav, EuiText } from '@elastic/eui';
+import classes from 'classnames';
+import { connect } from 'react-redux';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import { FileTree as Tree, FileTreeItemType } from '../../../model';
+import { closeTreePath, fetchRepoTree, FetchRepoTreePayload, openTreePath } from '../../actions';
+import { EuiSideNavItem, MainRouteParams, PathTypes } from '../../common/types';
+import { RootState } from '../../reducers';
+import { encodeRevisionString } from '../../utils/url';
+
+interface Props extends RouteComponentProps {
+ node?: Tree;
+ closeTreePath: (paths: string) => void;
+ openTreePath: (paths: string) => void;
+ fetchRepoTree: (p: FetchRepoTreePayload) => void;
+ openedPaths: string[];
+ treeLoading?: boolean;
+}
+
+export class CodeFileTree extends React.Component {
+ public componentDidMount(): void {
+ const { path } = this.props.match.params;
+ if (path) {
+ this.props.openTreePath(path);
+ }
+ }
+
+ public fetchTree(path = '', isDir: boolean) {
+ const { resource, org, repo, revision } = this.props.match.params;
+ this.props.fetchRepoTree({
+ uri: `${resource}/${org}/${repo}`,
+ revision,
+ path: path || '',
+ isDir,
+ });
+ }
+
+ public onClick = (node: Tree) => {
+ const { resource, org, repo, revision, path } = this.props.match.params;
+ if (!(path === node.path)) {
+ let pathType: PathTypes;
+ if (node.type === FileTreeItemType.Link || node.type === FileTreeItemType.File) {
+ pathType = PathTypes.blob;
+ } else {
+ pathType = PathTypes.tree;
+ }
+ this.props.history.push(
+ `/${resource}/${org}/${repo}/${pathType}/${encodeRevisionString(revision)}/${node.path}`
+ );
+ }
+ };
+
+ public toggleTree = (path: string) => {
+ if (this.isPathOpen(path)) {
+ this.props.closeTreePath(path);
+ } else {
+ this.props.openTreePath(path);
+ }
+ };
+
+ public flattenDirectory: (node: Tree) => Tree[] = (node: Tree) => {
+ if (node.childrenCount === 1 && node.children![0].type === FileTreeItemType.Directory) {
+ return [node, ...this.flattenDirectory(node.children![0])];
+ } else {
+ return [node];
+ }
+ };
+
+ public scrollIntoView(el: any) {
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ const elemTop = rect.top;
+ const elemBottom = rect.bottom;
+ const isVisible = elemTop >= 0 && elemBottom <= window.innerHeight;
+ if (!isVisible) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
+ }
+ }
+ }
+
+ public getItemRenderer = (node: Tree, forceOpen: boolean, flattenFrom?: Tree) => () => {
+ const className = 'codeFileTree__item kbn-resetFocusState';
+ let bg = null;
+ if (this.props.match.params.path === node.path) {
+ bg = this.scrollIntoView(el)} className="codeFileTree__node--fullWidth" />;
+ }
+ const onClick = () => {
+ const path = flattenFrom ? flattenFrom.path! : node.path!;
+ this.toggleTree(path);
+ this.onClick(node);
+ };
+ switch (node.type) {
+ case FileTreeItemType.Directory: {
+ return (
+
+
+ {forceOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {`${node.name}/`}
+
+
+
+ {bg}
+
+ );
+ }
+ case FileTreeItemType.Submodule: {
+ return (
+
+
+
+
+
+ {node.name}
+
+
+
+ {bg}
+
+ );
+ }
+ case FileTreeItemType.Link: {
+ return (
+
+
+
+
+
+ {node.name}
+
+
+
+ {bg}
+
+ );
+ }
+ case FileTreeItemType.File: {
+ return (
+
+
+
+
+
+ {node.name}
+
+
+
+ {bg}
+
+ );
+ }
+ }
+ };
+
+ public treeToItems = (node: Tree): EuiSideNavItem => {
+ const forceOpen =
+ node.type === FileTreeItemType.Directory ? this.isPathOpen(node.path!) : false;
+ const data: EuiSideNavItem = {
+ id: node.name,
+ name: node.name,
+ isSelected: false,
+ renderItem: this.getItemRenderer(node, forceOpen),
+ forceOpen,
+ onClick: () => void 0,
+ };
+ if (node.type === FileTreeItemType.Directory && Number(node.childrenCount) > 0) {
+ const nodes = this.flattenDirectory(node);
+ const length = nodes.length;
+ if (length > 1 && !(this.props.match.params.path === node.path)) {
+ data.name = nodes.map(n => n.name).join('/');
+ data.id = data.name;
+ const lastNode = nodes[length - 1];
+ const flattenNode = {
+ ...lastNode,
+ name: data.name,
+ id: data.id,
+ };
+ data.forceOpen = this.isPathOpen(node.path!);
+ data.renderItem = this.getItemRenderer(flattenNode, data.forceOpen, node);
+ if (data.forceOpen && Number(flattenNode.childrenCount) > 0) {
+ data.items = flattenNode.children!.map(this.treeToItems);
+ }
+ } else if (forceOpen && node.children) {
+ data.items = node.children.map(this.treeToItems);
+ }
+ }
+ return data;
+ };
+
+ public render() {
+ const items = [
+ {
+ name: '',
+ id: '',
+ items: (this.props.node!.children || []).map(this.treeToItems),
+ },
+ ];
+ return this.props.node &&
;
+ }
+
+ private isPathOpen(path: string) {
+ return this.props.openedPaths.includes(path);
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ node: state.file.tree,
+ openedPaths: state.file.openedPaths,
+ treeLoading: state.file.loading,
+});
+
+const mapDispatchToProps = {
+ fetchRepoTree,
+ closeTreePath,
+ openTreePath,
+};
+
+export const FileTree = withRouter(
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )(CodeFileTree)
+);
diff --git a/x-pack/plugins/code/public/components/help_menu/help_menu.tsx b/x-pack/plugins/code/public/components/help_menu/help_menu.tsx
new file mode 100644
index 0000000000000..1a45cb34418a8
--- /dev/null
+++ b/x-pack/plugins/code/public/components/help_menu/help_menu.tsx
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import chrome from 'ui/chrome';
+import { EuiButton, EuiHorizontalRule, EuiText, EuiSpacer } from '@elastic/eui';
+import { documentationLinks } from '../../lib/documentation_links';
+
+export class HelpMenu extends React.PureComponent {
+ public render() {
+ return (
+
+
+
+
+ For Code specific information
+
+
+
+ Setup Guide
+
+
+
+ Code documentation
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/help_menu/index.ts b/x-pack/plugins/code/public/components/help_menu/index.ts
new file mode 100644
index 0000000000000..47682169c51e0
--- /dev/null
+++ b/x-pack/plugins/code/public/components/help_menu/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { HelpMenu } from './help_menu';
diff --git a/x-pack/plugins/code/public/components/hover/hover_buttons.tsx b/x-pack/plugins/code/public/components/hover/hover_buttons.tsx
new file mode 100644
index 0000000000000..b9200676a730c
--- /dev/null
+++ b/x-pack/plugins/code/public/components/hover/hover_buttons.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiButton, EuiFlexGroup } from '@elastic/eui';
+// @ts-ignore
+import { renderMarkdown } from 'monaco-editor/esm/vs/base/browser/htmlContentRenderer';
+// @ts-ignore
+import { tokenizeToString } from 'monaco-editor/esm/vs/editor/common/modes/textToHtmlTokenizer';
+import React from 'react';
+import { HoverState } from './hover_widget';
+
+export interface HoverButtonProps {
+ state: HoverState;
+ gotoDefinition: () => void;
+ findReferences: () => void;
+}
+
+export class HoverButtons extends React.PureComponent
{
+ public render() {
+ return (
+
+
+
+ Goto Definition
+
+
+ Find Reference
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/hover/hover_widget.tsx b/x-pack/plugins/code/public/components/hover/hover_widget.tsx
new file mode 100644
index 0000000000000..d58eda98aec14
--- /dev/null
+++ b/x-pack/plugins/code/public/components/hover/hover_widget.tsx
@@ -0,0 +1,101 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiText } from '@elastic/eui';
+// @ts-ignore
+import { renderMarkdown } from 'monaco-editor/esm/vs/base/browser/htmlContentRenderer';
+// @ts-ignore
+import { tokenizeToString } from 'monaco-editor/esm/vs/editor/common/modes/textToHtmlTokenizer';
+import React from 'react';
+import { MarkedString } from 'vscode-languageserver-types';
+
+export interface HoverWidgetProps {
+ state: HoverState;
+ contents?: MarkedString[];
+ gotoDefinition: () => void;
+ findReferences: () => void;
+}
+
+export enum HoverState {
+ LOADING,
+ INITIALIZING,
+ READY,
+}
+
+export class HoverWidget extends React.PureComponent {
+ public render() {
+ let contents;
+
+ switch (this.props.state) {
+ case HoverState.READY:
+ contents = this.renderContents();
+ break;
+ case HoverState.INITIALIZING:
+ contents = this.renderInitialting();
+ break;
+ case HoverState.LOADING:
+ default:
+ contents = this.renderLoading();
+ }
+ return {contents} ;
+ }
+
+ private renderLoading() {
+ return (
+
+ );
+ }
+
+ private renderContents() {
+ return this.props
+ .contents!.filter(content => !!content)
+ .map((markedString, idx) => {
+ let markdown: string;
+ if (typeof markedString === 'string') {
+ markdown = markedString;
+ } else if (markedString.language) {
+ markdown = '```' + markedString.language + '\n' + markedString.value + '\n```';
+ } else {
+ markdown = markedString.value;
+ }
+ const renderedContents: string = renderMarkdown(
+ { value: markdown },
+ {
+ codeBlockRenderer: (language: string, value: string) => {
+ const code = tokenizeToString(value, language);
+ return `${code} `;
+ },
+ }
+ ).innerHTML;
+ return (
+
+ );
+ });
+ }
+
+ private renderInitialting() {
+ return (
+
+ {/*
+ // @ts-ignore */}
+
+ Language Server is initializing…
+
+ Depending on the size of your repo, this could take a few minutes.
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/main/blame.tsx b/x-pack/plugins/code/public/components/main/blame.tsx
new file mode 100644
index 0000000000000..7fde1e8f19778
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/blame.tsx
@@ -0,0 +1,48 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText, EuiTextColor } from '@elastic/eui';
+import _ from 'lodash';
+import moment from 'moment';
+import React from 'react';
+import { GitBlame } from '../../../common/git_blame';
+
+export class Blame extends React.PureComponent<{ blame: GitBlame; isFirstLine: boolean }> {
+ public render(): React.ReactNode {
+ const { blame, isFirstLine } = this.props;
+ return (
+
+
+
+
+
+
+
+
+ {blame.commit.message}
+
+
+
+
+
+
+ {moment(blame.commit.date).fromNow()}
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/main/breadcrumb.tsx b/x-pack/plugins/code/public/components/main/breadcrumb.tsx
new file mode 100644
index 0000000000000..99d79c532927d
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/breadcrumb.tsx
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// @ts-ignore
+import { EuiBreadcrumbs } from '@elastic/eui';
+import React from 'react';
+import { MainRouteParams } from '../../common/types';
+import { encodeRevisionString } from '../../utils/url';
+
+interface Props {
+ routeParams: MainRouteParams;
+}
+export class Breadcrumb extends React.PureComponent {
+ public render() {
+ const { resource, org, repo, revision, path } = this.props.routeParams;
+ const repoUri = `${resource}/${org}/${repo}`;
+
+ const breadcrumbs: Array<{ text: string; href: string; className?: string }> = [];
+ const pathSegments = path ? path.split('/') : [];
+
+ pathSegments.forEach((p, index) => {
+ const paths = pathSegments.slice(0, index + 1);
+ const href = `#${repoUri}/tree/${encodeRevisionString(revision)}/${paths.join('/')}`;
+ breadcrumbs.push({
+ text: p,
+ href,
+ className: 'codeNoMinWidth',
+ });
+ });
+ return ;
+ }
+}
diff --git a/x-pack/plugins/code/public/components/main/clone_status.tsx b/x-pack/plugins/code/public/components/main/clone_status.tsx
new file mode 100644
index 0000000000000..8d16e26859017
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/clone_status.tsx
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui';
+import theme from '@elastic/eui/dist/eui_theme_light.json';
+import React from 'react';
+import { CloneProgress } from '../../../model';
+
+interface Props {
+ repoName: string;
+ progress: number;
+ cloneProgress: CloneProgress;
+}
+
+export const CloneStatus = (props: Props) => {
+ const { progress: progressRate, cloneProgress, repoName } = props;
+ let progress = `Receiving objects: ${progressRate.toFixed(2)}%`;
+ if (progressRate < 0) {
+ progress = 'Clone Failed';
+ } else if (cloneProgress) {
+ const { receivedObjects, totalObjects, indexedObjects } = cloneProgress;
+
+ if (receivedObjects === totalObjects) {
+ progress = `Indexing objects: ${progressRate.toFixed(
+ 2
+ )}% (${indexedObjects}/${totalObjects})`;
+ } else {
+ progress = `Receiving objects: ${progressRate.toFixed(
+ 2
+ )}% (${receivedObjects}/${totalObjects})`;
+ }
+ }
+ return (
+
+
+
+
+
+ {repoName} is cloning
+
+
+
+
+
+ Your project will be available when this process is complete
+
+
+
+
+
+
+ {progress}
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/code/public/components/main/commit_history.tsx b/x-pack/plugins/code/public/components/main/commit_history.tsx
new file mode 100644
index 0000000000000..61984ae28b163
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/commit_history.tsx
@@ -0,0 +1,152 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLoadingSpinner,
+ EuiPanel,
+ EuiText,
+ EuiTextColor,
+} from '@elastic/eui';
+import _ from 'lodash';
+import moment from 'moment';
+import React from 'react';
+import { connect } from 'react-redux';
+import { CommitInfo } from '../../../model/commit';
+import { CommitLink } from '../diff_page/commit_link';
+import { RootState } from '../../reducers';
+import { hasMoreCommitsSelector, treeCommitsSelector } from '../../selectors';
+import { fetchMoreCommits } from '../../actions';
+
+const COMMIT_ID_LENGTH = 8;
+
+const Commit = (props: { commit: CommitInfo; date: string; repoUri: string }) => {
+ const { date, commit } = props;
+ const { message, committer, id } = commit;
+ const commitId = id
+ .split('')
+ .slice(0, COMMIT_ID_LENGTH)
+ .join('');
+ return (
+
+
+
+ {message}
+
+
+
+ {committer} · {date}
+
+
+
+
+
+
+
+ );
+};
+
+const CommitGroup = (props: { commits: CommitInfo[]; date: string; repoUri: string }) => {
+ const commitList = props.commits.map(commit => (
+
+ ));
+ return (
+
+
+
+
+
+
+
+
+ Commits on {props.date}
+
+
+
+
+
{commitList}
+
+ );
+};
+
+export const CommitHistoryLoading = () => (
+
+
+
+);
+
+export const PageButtons = (props: {
+ loading?: boolean;
+ disabled: boolean;
+ onClick: () => void;
+}) => (
+
+
+
+ More
+
+
+
+);
+
+export const CommitHistoryComponent = (props: {
+ commits: CommitInfo[];
+ repoUri: string;
+ header: React.ReactNode;
+ loadingCommits?: boolean;
+ showPagination?: boolean;
+ hasMoreCommit?: boolean;
+ fetchMoreCommits: any;
+}) => {
+ const commits = _.groupBy(props.commits, commit => moment(commit.updated).format('YYYYMMDD'));
+ const commitDates = Object.keys(commits).sort((a, b) => b.localeCompare(a)); // sort desc
+ const commitList = commitDates.map(cd => (
+
+ ));
+ return (
+
+
{props.header}
+ {commitList}
+ {!props.showPagination && props.loadingCommits &&
}
+ {props.showPagination && (
+
props.fetchMoreCommits(props.repoUri)}
+ loading={props.loadingCommits}
+ />
+ )}
+
+ );
+};
+
+const mapStateToProps = (state: RootState) => ({
+ file: state.file.file,
+ commits: treeCommitsSelector(state) || [],
+ loadingCommits: state.file.loadingCommits,
+ hasMoreCommit: hasMoreCommitsSelector(state),
+});
+
+const mapDispatchToProps = {
+ fetchMoreCommits,
+};
+export const CommitHistory = connect(
+ mapStateToProps,
+ mapDispatchToProps
+ // @ts-ignore
+)(CommitHistoryComponent);
diff --git a/x-pack/plugins/code/public/components/main/content.tsx b/x-pack/plugins/code/public/components/main/content.tsx
new file mode 100644
index 0000000000000..be4ef225090f8
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/content.tsx
@@ -0,0 +1,395 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiButton, EuiButtonGroup, EuiFlexGroup, EuiTitle } from '@elastic/eui';
+import 'github-markdown-css/github-markdown.css';
+import React from 'react';
+import Markdown from 'react-markdown';
+import { connect } from 'react-redux';
+import { RouteComponentProps } from 'react-router-dom';
+import { withRouter } from 'react-router-dom';
+import chrome from 'ui/chrome';
+
+import { RepositoryUtils } from '../../../common/repository_utils';
+import {
+ FileTree,
+ FileTreeItemType,
+ SearchScope,
+ WorkerReservedProgress,
+ Repository,
+} from '../../../model';
+import { CommitInfo, ReferenceInfo } from '../../../model/commit';
+import { changeSearchScope, FetchFileResponse, SearchOptions } from '../../actions';
+import { MainRouteParams, PathTypes } from '../../common/types';
+import { RepoState, RepoStatus, RootState } from '../../reducers';
+import {
+ currentTreeSelector,
+ hasMoreCommitsSelector,
+ repoUriSelector,
+ statusSelector,
+} from '../../selectors';
+import { encodeRevisionString, history } from '../../utils/url';
+import { Editor } from '../editor/editor';
+import { CloneStatus } from './clone_status';
+import { CommitHistory } from './commit_history';
+import { Directory } from './directory';
+import { ErrorPanel } from './error_panel';
+import { NotFound } from './not_found';
+import { TopBar } from './top_bar';
+
+interface Props extends RouteComponentProps {
+ isNotFound: boolean;
+ repoStatus?: RepoStatus;
+ tree: FileTree;
+ file: FetchFileResponse | undefined;
+ currentTree: FileTree | undefined;
+ commits: CommitInfo[];
+ branches: ReferenceInfo[];
+ hasMoreCommits: boolean;
+ loadingCommits: boolean;
+ onSearchScopeChanged: (s: SearchScope) => void;
+ repoScope: string[];
+ searchOptions: SearchOptions;
+ currentRepository?: Repository;
+}
+const LANG_MD = 'markdown';
+
+enum ButtonOption {
+ Code = 'Code',
+ Blame = 'Blame',
+ History = 'History',
+ Folder = 'Directory',
+}
+
+enum ButtonLabel {
+ Code = 'Code',
+ Content = 'Content',
+ Download = 'Download',
+ Raw = 'Raw',
+}
+
+class CodeContent extends React.PureComponent {
+ public findNode = (pathSegments: string[], node: FileTree): FileTree | undefined => {
+ if (!node) {
+ return undefined;
+ } else if (pathSegments.length === 0) {
+ return node;
+ } else if (pathSegments.length === 1) {
+ return (node.children || []).find(n => n.name === pathSegments[0]);
+ } else {
+ const currentFolder = pathSegments.shift();
+ const nextNode = (node.children || []).find(n => n.name === currentFolder);
+ if (nextNode) {
+ return this.findNode(pathSegments, nextNode);
+ } else {
+ return undefined;
+ }
+ }
+ };
+
+ public switchButton = (id: string) => {
+ const { path, resource, org, repo, revision } = this.props.match.params;
+ const repoUri = `${resource}/${org}/${repo}`;
+ switch (id) {
+ case ButtonOption.Code:
+ history.push(
+ `/${repoUri}/${PathTypes.blob}/${encodeRevisionString(revision)}/${path || ''}`
+ );
+ break;
+ case ButtonOption.Folder:
+ history.push(
+ `/${repoUri}/${PathTypes.tree}/${encodeRevisionString(revision)}/${path || ''}`
+ );
+ break;
+ case ButtonOption.Blame:
+ history.push(
+ `/${repoUri}/${PathTypes.blame}/${encodeRevisionString(revision)}/${path || ''}`
+ );
+ break;
+ case ButtonOption.History:
+ history.push(
+ `/${repoUri}/${PathTypes.commits}/${encodeRevisionString(revision)}/${path || ''}`
+ );
+ break;
+ }
+ };
+
+ public openRawFile = () => {
+ const { path, resource, org, repo, revision } = this.props.match.params;
+ const repoUri = `${resource}/${org}/${repo}`;
+ window.open(
+ chrome.addBasePath(`/app/code/repo/${repoUri}/raw/${encodeRevisionString(revision)}/${path}`)
+ );
+ };
+
+ public renderButtons = () => {
+ let buttonId: string | undefined;
+ switch (this.props.match.params.pathType) {
+ case PathTypes.blame:
+ buttonId = ButtonOption.Blame;
+ break;
+ case PathTypes.blob:
+ buttonId = ButtonOption.Code;
+ break;
+ case PathTypes.tree:
+ buttonId = ButtonOption.Folder;
+ break;
+ case PathTypes.commits:
+ buttonId = ButtonOption.History;
+ break;
+ default:
+ break;
+ }
+ const currentTree = this.props.currentTree;
+ if (
+ this.props.file &&
+ currentTree &&
+ (currentTree.type === FileTreeItemType.File || currentTree.type === FileTreeItemType.Link)
+ ) {
+ const { isUnsupported, isOversize, isImage, lang } = this.props.file;
+ const isMarkdown = lang === LANG_MD;
+ const isText = !isUnsupported && !isOversize && !isImage;
+
+ const buttonOptions = [
+ {
+ id: ButtonOption.Code,
+ label: isText && !isMarkdown ? ButtonLabel.Code : ButtonLabel.Content,
+ },
+ {
+ id: ButtonOption.Blame,
+ label: ButtonOption.Blame,
+ isDisabled: isUnsupported || isImage || isOversize,
+ },
+ {
+ id: ButtonOption.History,
+ label: ButtonOption.History,
+ },
+ ];
+ const rawButtonOptions = [
+ { id: 'Raw', label: isText ? ButtonLabel.Raw : ButtonLabel.Download },
+ ];
+
+ return (
+
+
+
+
+ );
+ } else {
+ return (
+
+
+
+ );
+ }
+ };
+
+ public render() {
+ return (
+
+
+ {this.renderContent()}
+
+ );
+ }
+
+ public shouldRenderProgress() {
+ if (!this.props.repoStatus) {
+ return false;
+ }
+ const { progress, cloneProgress, state } = this.props.repoStatus;
+ return (
+ !!progress &&
+ state === RepoState.CLONING &&
+ progress < WorkerReservedProgress.COMPLETED &&
+ !RepositoryUtils.hasFullyCloned(cloneProgress)
+ );
+ }
+
+ public renderProgress() {
+ if (!this.props.repoStatus) {
+ return null;
+ }
+ const { progress, cloneProgress } = this.props.repoStatus;
+ const { org, repo } = this.props.match.params;
+ return (
+
+ );
+ }
+
+ public renderContent() {
+ if (this.props.isNotFound) {
+ return ;
+ }
+ if (this.shouldRenderProgress()) {
+ return this.renderProgress();
+ }
+
+ const { file, match, tree } = this.props;
+ const { path, pathType, resource, org, repo, revision } = match.params;
+ const repoUri = `${resource}/${org}/${repo}`;
+ switch (pathType) {
+ case PathTypes.tree:
+ const node = this.findNode(path ? path.split('/') : [], tree);
+ return (
+
+
+
+
+ Recent Commits
+
+
+ View All
+
+
+ }
+ />
+
+ );
+ case PathTypes.blob:
+ if (!file) {
+ return null;
+ }
+ const {
+ lang: fileLanguage,
+ content: fileContent,
+ isUnsupported,
+ isOversize,
+ isImage,
+ } = file;
+ if (isUnsupported) {
+ return (
+ Unsupported File}
+ content="Unfortunately that’s an unsupported file type and we’re unable to render it here."
+ />
+ );
+ }
+ if (isOversize) {
+ return (
+ File is too big}
+ content="Sorry about that, but we can’t show files that are this big right now."
+ />
+ );
+ }
+ if (fileLanguage === LANG_MD) {
+ return (
+
+
+
+ );
+ } else if (isImage) {
+ const rawUrl = chrome.addBasePath(`/app/code/repo/${repoUri}/raw/${revision}/${path}`);
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ case PathTypes.blame:
+ return (
+
+
+
+ );
+ case PathTypes.commits:
+ return (
+
+
+ Commit History
+
+ }
+ showPagination={true}
+ />
+
+ );
+ }
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ isNotFound: state.file.isNotFound,
+ file: state.file.file,
+ tree: state.file.tree,
+ currentTree: currentTreeSelector(state),
+ branches: state.file.branches,
+ hasMoreCommits: hasMoreCommitsSelector(state),
+ loadingCommits: state.file.loadingCommits,
+ repoStatus: statusSelector(state, repoUriSelector(state)),
+ searchOptions: state.search.searchOptions,
+ currentRepository: state.repository.currentRepository,
+});
+
+const mapDispatchToProps = {
+ onSearchScopeChanged: changeSearchScope,
+};
+
+export const Content = withRouter(
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ // @ts-ignore
+ )(CodeContent)
+);
diff --git a/x-pack/plugins/code/public/components/main/directory.tsx b/x-pack/plugins/code/public/components/main/directory.tsx
new file mode 100644
index 0000000000000..e3d60f2ab67d9
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/directory.tsx
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle, IconType } from '@elastic/eui';
+import React from 'react';
+import { Link, RouteComponentProps, withRouter } from 'react-router-dom';
+import { FileTree, FileTreeItemType } from '../../../model';
+import { MainRouteParams, PathTypes } from '../../common/types';
+import { encodeRevisionString } from '../../utils/url';
+
+interface DirectoryNodesProps {
+ title: string;
+ nodes: FileTree[];
+ getUrl: (path: string) => string;
+}
+
+const DirectoryNodes = (props: DirectoryNodesProps) => {
+ const typeIconMap: { [k: number]: IconType } = {
+ [FileTreeItemType.File]: 'document',
+ [FileTreeItemType.Directory]: 'folderClosed',
+ [FileTreeItemType.Link]: 'symlink',
+ [FileTreeItemType.Submodule]: 'submodule',
+ };
+ const nodes = props.nodes.map(n => (
+
+
+
+
+
+ {n.name}
+
+
+
+
+ ));
+ return (
+
+
+
+
+ {props.title}
+
+
+
+ {nodes}
+
+
+
+ );
+};
+
+interface Props extends RouteComponentProps {
+ node?: FileTree;
+}
+
+export const Directory = withRouter((props: Props) => {
+ let files: FileTree[] = [];
+ let folders: FileTree[] = [];
+ if (props.node && props.node.children) {
+ files = props.node.children.filter(
+ n => n.type === FileTreeItemType.File || n.type === FileTreeItemType.Link
+ );
+ folders = props.node.children.filter(
+ n => n.type === FileTreeItemType.Directory || n.type === FileTreeItemType.Submodule
+ );
+ }
+ const { resource, org, repo, revision } = props.match.params;
+ const getUrl = (pathType: PathTypes) => (path: string) =>
+ `/${resource}/${org}/${repo}/${pathType}/${encodeRevisionString(revision)}/${path}`;
+ const fileList = ;
+ const folderList = (
+
+ );
+ return (
+
+ {files.length > 0 && fileList}
+ {folders.length > 0 && folderList}
+
+ );
+});
diff --git a/x-pack/plugins/code/public/components/main/error_panel.tsx b/x-pack/plugins/code/public/components/main/error_panel.tsx
new file mode 100644
index 0000000000000..dfb39be64c4b3
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/error_panel.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiButton, EuiPanel, EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui';
+import React, { ReactNode } from 'react';
+import { history } from '../../utils/url';
+import { ErrorIcon } from '../shared/icons';
+
+export const ErrorPanel = (props: { title: ReactNode; content: string }) => {
+ return (
+
+
+
+
+
+
+ {props.title}
+
+
+ {props.content}
+
+
+
+
+
+ Go Back
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/code/public/components/main/main.scss b/x-pack/plugins/code/public/components/main/main.scss
new file mode 100644
index 0000000000000..f833c363591aa
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/main.scss
@@ -0,0 +1,281 @@
+.code-auto-overflow {
+ overflow: auto;
+}
+
+.code-auto-overflow-y {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.codeOverflowHidden {
+ overflow: hidden;
+}
+
+.code-markdown-container {
+ padding: $euiSizeXL;
+ overflow: auto;
+}
+
+.code-auto-margin {
+ margin: auto;
+}
+
+.code-navigation__sidebar {
+ background-color: $euiColorLightestShade;
+ width: 16rem;
+ border-right: $euiBorderThin;
+ display: flex;
+ flex-shrink: 0;
+ flex-grow: 0;
+ flex-direction: column;
+ height: 100%;
+ div[role="tablist"] {
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ div[role="tabpanel"] {
+ @include euiScrollBar;
+ width: 100%;
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow: auto;
+ }
+}
+
+.codeFileTree--container {
+ flex-grow: 1;
+ flex-shrink: 1;
+ padding: $euiSizeM;
+ background-color: $euiColorLightestShade;
+ position: relative;
+ display: inline-block;
+ min-width: 100%;
+ height: 100%;
+}
+
+.codeFileTree__icon {
+ margin-right: $euiSizeS;
+}
+
+.code-directory__node {
+ width: calc(200rem / 14);
+ padding: 0 $euiSizeS;
+ border-radius: $euiBorderRadius;
+ white-space: nowrap;
+ color: $euiColorFullShade;
+ &:hover {
+ background-color: rgba($euiColorGhost, .10);
+ cursor: pointer;
+ }
+}
+
+.code-fileNodeName {
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: $euiSizeS;
+}
+
+.code-timeline {
+ border-left: $euiBorderThick;
+ margin-left: $euiSizeXS;
+ padding: $euiSizeS 0 $euiSizeS $euiSizeS;
+}
+
+.code-timeline__marker {
+ width: $euiSizeS;
+ height: $euiSizeS;
+ border-radius: $euiSizeS / 2;
+ background-color: $euiBorderColor;
+ margin: auto;
+}
+
+.code-timeline__commit-container {
+ margin: 0 0 $euiSizeXS $euiSizeM;
+ .euiPanel:not(:first-of-type), .euiPanel:not(:last-of-type) {
+ border-radius: 0;
+ }
+}
+
+.euiPanel.code-timeline__commit--root {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ &:not(:first-child) {
+ border-top: none;
+ }
+ &:not(:first-child):not(:last-child) {
+ border-radius: 0;
+ }
+ &:first-child {
+ border-radius: $euiSizeXS $euiSizeXS 0 0;
+ }
+ &:last-child {
+ border-radius: 0 0 $euiSizeXS $euiSizeXS;
+ }
+ &:only-child{
+ border-radius: $euiSizeXS
+ }
+}
+
+.code-top-bar__container {
+ box-sizing: content-box;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: $euiSizeS;
+ min-height: 80px;
+ border-bottom: $euiBorderThin;
+ nav {
+ a {
+ display: inline;
+ }
+ div {
+ vertical-align: baseline;
+ }
+ }
+}
+
+.codeSearch__suggestion-item {
+ height: 3rem;
+ margin: 0 $euiSize;
+ border-radius: $euiSizeXS;
+ cursor: pointer;
+ width: calc(100% - $euiSizeXL);
+
+ &:hover {
+ background: $euiFocusBackgroundColor;
+ }
+}
+
+.codeSearch__suggestion-item--active {
+ background: $euiFocusBackgroundColor;
+}
+
+.codeSearch-suggestion--inner {
+ display: flex;
+ align-items: stretch;
+ flex-grow: 1;
+ align-items: center;
+ white-space: nowrap;
+}
+
+.codeSearch__suggestion-text {
+ color: $euiColorFullShade;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 0;
+ flex-basis: auto;
+ font-family: $euiCodeFontFamily;
+ margin-right: $euiSizeXL;
+ width: auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: $euiSizeXS $euiSizeS;
+ font-size: $euiFontSizeS;
+}
+
+.codeSearch__full-text-button {
+ border-top: $euiBorderWidthThin solid $euiBorderColor;
+ padding: $euiSizeS;
+ text-align: center;
+ font-weight: bold;
+ background: $euiColorLightShade;
+ margin: $euiSizeS;
+ padding: $euiSizeS;
+ font-size: $euiFontSizeS;
+}
+
+.kbnTypeahead .kbnTypeahead__popover .kbnTypeahead__items {
+ overflow-x: hidden;
+}
+
+.codeSearch-suggestion__group {
+ border-top: $euiBorderThin;
+}
+
+.codeSearch-suggestion__group-header {
+ padding: $euiSizeL;
+}
+.codeSearch-suggestion__group-title {
+ font-weight: bold;
+ margin-left: $euiSizeS;
+ display: inline-block;
+}
+
+.codeSearch-suggestion__group-result {
+ color: $euiColorDarkShade;
+ font-size: $euiFontSizeXS;
+}
+
+.codeSearch-suggestion__link {
+ height: $euiSize;
+ line-height: $euiSize;
+ text-align: center;
+ font-size: $euiFontSizeXS;
+ margin: $euiSizeS;
+}
+
+.codeSearch-suggestion__description {
+ flex-grow: 1;
+ flex-basis: 0%;
+ display: flex;
+ flex-direction: column;
+ color: $euiColorDarkShade;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: $euiFontSizeXS;
+ padding: $euiSizeXS $euiSizeS;
+}
+
+.codeSearch-suggestion__token {
+ color: $euiColorFullShade;
+ box-sizing: border-box;
+ flex-grow: 0;
+ flex-basis: auto;
+ width: $euiSizeXL;
+ height: $euiSizeXL;
+ text-align: center;
+ overflow: hidden;
+ padding: $euiSizeXS;
+ justify-content: center;
+ align-items: center;
+ margin-left: $euiSizeXS;
+}
+
+.code-link {
+ margin: 0 $euiSizeS $euiSizeS;
+ border-radius: $euiBorderRadius;
+ &:focus {
+ text-decoration: underline;
+ }
+}
+
+.codeBlame__item {
+ padding: $euiSizeXS $euiSizeS;
+ border-top: $euiBorderThin;
+ &.codeBlame__item--first{
+ border-top: none;
+ }
+}
+
+
+.codeIcon__language {
+ fill: $euiColorDarkestShade;
+}
+
+.codeNoMinWidth {
+ min-width: unset !important;
+}
+
+.code-commit-id {
+ @include euiCodeFont;
+ height: calc(20rem / 14);
+ margin: auto 0 auto $euiSizeM;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.code-line-decoration {
+ border-right: $euiBorderThick;
+ width: 316px !important;
+}
diff --git a/x-pack/plugins/code/public/components/main/main.tsx b/x-pack/plugins/code/public/components/main/main.tsx
new file mode 100644
index 0000000000000..0fa2d2a92b44f
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/main.tsx
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { RouteComponentProps } from 'react-router-dom';
+
+import chrome from 'ui/chrome';
+import { MainRouteParams } from '../../common/types';
+import { ShortcutsProvider } from '../shortcuts';
+import { Content } from './content';
+import { SideTabs } from './side_tabs';
+import { structureSelector } from '../../selectors';
+import { RootState } from '../../reducers';
+
+interface Props extends RouteComponentProps {
+ loadingFileTree: boolean;
+ loadingStructureTree: boolean;
+ hasStructure: boolean;
+}
+
+class CodeMain extends React.Component {
+ public componentDidMount() {
+ this.setBreadcrumbs();
+ }
+
+ public componentDidUpdate() {
+ chrome.breadcrumbs.pop();
+ this.setBreadcrumbs();
+ }
+
+ public setBreadcrumbs() {
+ const { org, repo } = this.props.match.params;
+ chrome.breadcrumbs.push({ text: `${org} → ${repo}` });
+ }
+
+ public componentWillUnmount() {
+ chrome.breadcrumbs.pop();
+ }
+
+ public render() {
+ const { loadingFileTree, loadingStructureTree, hasStructure } = this.props;
+ return (
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ loadingFileTree: state.file.loading,
+ loadingStructureTree: state.symbol.loading,
+ hasStructure: structureSelector(state).length > 0 && !state.symbol.error,
+});
+
+export const Main = connect(mapStateToProps)(CodeMain);
diff --git a/x-pack/plugins/code/public/components/main/not_found.tsx b/x-pack/plugins/code/public/components/main/not_found.tsx
new file mode 100644
index 0000000000000..ed385a65c7004
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/not_found.tsx
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup } from '@elastic/eui';
+import React from 'react';
+import { ErrorPanel } from './error_panel';
+
+export const NotFound = () => (
+
+ 404}
+ content="Unfortunately that page doesn’t exist. You can try searching to find what you’re looking for."
+ />
+
+);
diff --git a/x-pack/plugins/code/public/components/main/search_bar.tsx b/x-pack/plugins/code/public/components/main/search_bar.tsx
new file mode 100644
index 0000000000000..b732546af9821
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/search_bar.tsx
@@ -0,0 +1,127 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ParsedUrlQuery } from 'querystring';
+import React from 'react';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import url from 'url';
+
+import { unique } from 'lodash';
+import { SearchScope, Repository } from '../../../model';
+import { MainRouteParams, SearchScopeText } from '../../common/types';
+import {
+ AutocompleteSuggestion,
+ FileSuggestionsProvider,
+ QueryBar,
+ RepositorySuggestionsProvider,
+ SymbolSuggestionsProvider,
+} from '../query_bar';
+import { Shortcut } from '../shortcuts';
+import { SearchOptions } from '../../actions';
+
+interface Props extends RouteComponentProps {
+ onSearchScopeChanged: (s: SearchScope) => void;
+ searchOptions: SearchOptions;
+ defaultSearchScope?: Repository;
+}
+
+export class CodeSearchBar extends React.Component {
+ public state = {
+ searchScope: SearchScope.DEFAULT,
+ };
+
+ public queryBar: any | null = null;
+
+ public suggestionProviders = [
+ new SymbolSuggestionsProvider(),
+ new FileSuggestionsProvider(),
+ new RepositorySuggestionsProvider(),
+ ];
+
+ public onSubmit = (queryString: string) => {
+ const { history } = this.props;
+ if (queryString.trim().length === 0) {
+ return;
+ }
+ const query: ParsedUrlQuery = {
+ q: queryString,
+ };
+ if (this.props.searchOptions.repoScope) {
+ // search from a repo page may have a default scope of this repo
+ if (this.props.searchOptions.defaultRepoScopeOn && this.props.defaultSearchScope) {
+ query.repoScope = unique([
+ ...this.props.searchOptions.repoScope.map(r => r.uri),
+ this.props.defaultSearchScope.uri,
+ ]).join(',');
+ } else {
+ query.repoScope = this.props.searchOptions.repoScope.map(r => r.uri).join(',');
+ }
+ }
+ if (this.state.searchScope === SearchScope.REPOSITORY) {
+ query.scope = SearchScope.REPOSITORY;
+ }
+ history.push(url.format({ pathname: '/search', query }));
+ };
+
+ public onSelect = (item: AutocompleteSuggestion) => {
+ this.props.history.push(item.selectUrl);
+ };
+
+ public render() {
+ return (
+
+ {
+ this.props.onSearchScopeChanged(SearchScope.REPOSITORY);
+ if (this.queryBar) {
+ this.queryBar.focusInput();
+ }
+ }}
+ />
+ {
+ this.props.onSearchScopeChanged(SearchScope.SYMBOL);
+ if (this.queryBar) {
+ this.queryBar.focusInput();
+ }
+ }}
+ />
+ {
+ this.props.onSearchScopeChanged(SearchScope.DEFAULT);
+ if (this.queryBar) {
+ this.queryBar.focusInput();
+ }
+ }}
+ />
+ {
+ if (instance) {
+ // @ts-ignore
+ this.queryBar = instance.getWrappedInstance();
+ }
+ }}
+ />
+
+ );
+ }
+}
+
+export const SearchBar = withRouter(CodeSearchBar);
diff --git a/x-pack/plugins/code/public/components/main/side_tabs.tsx b/x-pack/plugins/code/public/components/main/side_tabs.tsx
new file mode 100644
index 0000000000000..459dce3a82517
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/side_tabs.tsx
@@ -0,0 +1,119 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiLoadingSpinner, EuiSpacer, EuiTabbedContent, EuiText } from '@elastic/eui';
+import { parse as parseQuery } from 'querystring';
+import React from 'react';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import { QueryString } from 'ui/utils/query_string';
+import { MainRouteParams, PathTypes } from '../../common/types';
+import { FileTree } from '../file_tree/file_tree';
+import { Shortcut } from '../shortcuts';
+import { SymbolTree } from '../symbol_tree/symbol_tree';
+
+enum Tabs {
+ file = 'file',
+ structure = 'structure',
+}
+
+interface Props extends RouteComponentProps {
+ loadingFileTree: boolean;
+ loadingStructureTree: boolean;
+ hasStructure: boolean;
+}
+
+class CodeSideTabs extends React.PureComponent {
+ public get sideTab(): Tabs {
+ const { search } = this.props.location;
+ let qs = search;
+ if (search.charAt(0) === '?') {
+ qs = search.substr(1);
+ }
+ const tab = parseQuery(qs).tab;
+ return tab === Tabs.structure ? Tabs.structure : Tabs.file;
+ }
+
+ public renderLoadingSpinner(text: string) {
+ return (
+
+
+
+ Loading {text} tree
+
+
+
+
+
+ );
+ }
+
+ public get tabs() {
+ const fileTabContent = this.props.loadingFileTree ? (
+ this.renderLoadingSpinner('file')
+ ) : (
+ { }
+ );
+ const structureTabContent = this.props.loadingStructureTree ? (
+ this.renderLoadingSpinner('structure')
+ ) : (
+
+ );
+ return [
+ {
+ id: Tabs.file,
+ name: 'File',
+ content: fileTabContent,
+ isSelected: Tabs.file === this.sideTab,
+ 'data-test-subj': 'codeFileTreeTab',
+ },
+ {
+ id: Tabs.structure,
+ name: 'Structure',
+ content: structureTabContent,
+ isSelected: Tabs.structure === this.sideTab,
+ disabled: this.props.match.params.pathType === PathTypes.tree || !this.props.hasStructure,
+ 'data-test-subj': 'codeStructureTreeTab',
+ },
+ ];
+ }
+
+ public switchTab = (tab: Tabs) => {
+ const { history } = this.props;
+ const { pathname, search } = history.location;
+ // @ts-ignore
+ history.push(QueryString.replaceParamInUrl(`${pathname}${search}`, 'tab', tab));
+ };
+
+ public render() {
+ return (
+
+ t.id === this.sideTab)}
+ onTabClick={tab => this.switchTab(tab.id as Tabs)}
+ expand={true}
+ selectedTab={this.tabs.find(t => t.id === this.sideTab)}
+ />
+
+
+ );
+ }
+ private toggleTab = () => {
+ const currentTab = this.sideTab;
+ if (currentTab === Tabs.file) {
+ this.switchTab(Tabs.structure);
+ } else {
+ this.switchTab(Tabs.file);
+ }
+ };
+}
+
+export const SideTabs = withRouter(CodeSideTabs);
diff --git a/x-pack/plugins/code/public/components/main/top_bar.tsx b/x-pack/plugins/code/public/components/main/top_bar.tsx
new file mode 100644
index 0000000000000..905612ab4a1be
--- /dev/null
+++ b/x-pack/plugins/code/public/components/main/top_bar.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui';
+import React, { ChangeEvent } from 'react';
+import { SearchScope, Repository } from '../../../model';
+import { ReferenceInfo } from '../../../model/commit';
+import { MainRouteParams } from '../../common/types';
+import { encodeRevisionString } from '../../utils/url';
+import { history } from '../../utils/url';
+import { Breadcrumb } from './breadcrumb';
+import { SearchBar } from './search_bar';
+import { SearchOptions } from '../../actions';
+
+interface Props {
+ routeParams: MainRouteParams;
+ onSearchScopeChanged: (s: SearchScope) => void;
+ buttons: React.ReactNode;
+ searchOptions: SearchOptions;
+ branches: ReferenceInfo[];
+ defaultSearchScope?: Repository;
+}
+
+export class TopBar extends React.Component {
+ public state = {
+ value: 'master',
+ };
+
+ public onChange = (e: ChangeEvent) => {
+ const { resource, org, repo, path = '', pathType } = this.props.routeParams;
+ this.setState({
+ value: e.target.value,
+ });
+ const revision = this.props.branches.find(b => b.name === e.target.value)!.commit.id;
+ history.push(
+ `/${resource}/${org}/${repo}/${pathType}/${encodeRevisionString(revision)}/${path}`
+ );
+ };
+
+ public render() {
+ return (
+
+
+
+
+
+
+ ({ value: b.name, text: b.name }))}
+ onChange={this.onChange}
+ />
+
+
+
+
+ {this.props.buttons}
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/components/__fixtures__/props.json b/x-pack/plugins/code/public/components/query_bar/components/__fixtures__/props.json
new file mode 100644
index 0000000000000..942dbeca5f814
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/__fixtures__/props.json
@@ -0,0 +1,55 @@
+{
+ "file": {
+ "type": "file",
+ "total": 1,
+ "hasMore": false,
+ "suggestions": [
+ {
+ "description": "This is a file",
+ "end": 10,
+ "start": 1,
+ "text": "src/foo/bar.java",
+ "tokenType": "",
+ "selectUrl": "http://github.com/elastic/elasticsearch/src/foo/bar.java"
+ }
+ ]
+ },
+ "repository": {
+ "type": "repository",
+ "total": 2,
+ "hasMore": true,
+ "suggestions": [
+ {
+ "description": "",
+ "end": 10,
+ "start": 1,
+ "text": "elastic/kibana",
+ "tokenType": "",
+ "selectUrl": "http://github.com/elastic/kibana"
+ },
+ {
+ "description": "",
+ "end": 10,
+ "start": 1,
+ "text": "elastic/elasticsearch",
+ "tokenType": "",
+ "selectUrl": "http://github.com/elastic/elasticsearch"
+ }
+ ]
+ },
+ "symbol": {
+ "type": "symbol",
+ "total": 1,
+ "hasMore": false,
+ "suggestions": [
+ {
+ "description": "elastic/elasticsearch > src/foo/bar.java",
+ "end": 10,
+ "start": 1,
+ "text": "java.lang.String",
+ "tokenType": "tokenClass",
+ "selectUrl": "http://github.com/elastic/elasticsearch/src/foo/bar.java"
+ }
+ ]
+ }
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap b/x-pack/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap
new file mode 100644
index 0000000000000..481d0717555e9
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap
@@ -0,0 +1,1437 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`render correctly with empty query string 1`] = `
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
,
+ "value": "default",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Symbols
+ ,
+ "value": "symbol",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Repositories
+ ,
+ "value": "repository",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Files
+ ,
+ "value": "file",
+ },
+ ]
+ }
+ style={
+ Object {
+ "width": "14.285714285714286rem",
+ }
+ }
+ valueOfSelected="default"
+ >
+
+
+
+
+ Search Everything
+
+ ,
+ "value": "default",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Symbols
+ ,
+ "value": "symbol",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Repositories
+ ,
+ "value": "repository",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Files
+ ,
+ "value": "file",
+ },
+ ]
+ }
+ style={
+ Object {
+ "width": "14.285714285714286rem",
+ }
+ }
+ value="default"
+ />
+ }
+ className="euiSuperSelect"
+ closePopover={[Function]}
+ hasArrow={false}
+ isOpen={false}
+ ownFocus={false}
+ panelClassName="euiSuperSelect__popoverPanel"
+ panelPaddingSize="none"
+ popoverRef={[Function]}
+ >
+
+
+
+
+
+
+
+ Search Everything
+
+
,
+ "value": "default",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Symbols
+ ,
+ "value": "symbol",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Repositories
+ ,
+ "value": "repository",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Files
+ ,
+ "value": "file",
+ },
+ ]
+ }
+ style={
+ Object {
+ "width": "14.285714285714286rem",
+ }
+ }
+ value="default"
+ >
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
,
+ }
+ }
+ >
+
+
+
+
+ Search Everything
+
+
+ }
+ >
+ Select an option:
+
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
+
+ , is selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`render correctly with input query string changed 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
,
+ "value": "default",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Symbols
+ ,
+ "value": "symbol",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Repositories
+ ,
+ "value": "repository",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Files
+ ,
+ "value": "file",
+ },
+ ]
+ }
+ style={
+ Object {
+ "width": "14.285714285714286rem",
+ }
+ }
+ valueOfSelected="default"
+ >
+
+
+
+
+ Search Everything
+
+ ,
+ "value": "default",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Symbols
+ ,
+ "value": "symbol",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Repositories
+ ,
+ "value": "repository",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Files
+ ,
+ "value": "file",
+ },
+ ]
+ }
+ style={
+ Object {
+ "width": "14.285714285714286rem",
+ }
+ }
+ value="default"
+ />
+ }
+ className="euiSuperSelect"
+ closePopover={[Function]}
+ hasArrow={false}
+ isOpen={false}
+ ownFocus={false}
+ panelClassName="euiSuperSelect__popoverPanel"
+ panelPaddingSize="none"
+ popoverRef={[Function]}
+ >
+
+
+
+
+
+
+
+ Search Everything
+
+
,
+ "value": "default",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Symbols
+ ,
+ "value": "symbol",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Repositories
+ ,
+ "value": "repository",
+ },
+ Object {
+ "inputDisplay":
+
+
+ Search Files
+ ,
+ "value": "file",
+ },
+ ]
+ }
+ style={
+ Object {
+ "width": "14.285714285714286rem",
+ }
+ }
+ value="default"
+ >
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
,
+ }
+ }
+ >
+
+
+
+
+ Search Everything
+
+
+ }
+ >
+ Select an option:
+
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
+
+ , is selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search Everything
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/code/public/components/query_bar/components/index.ts b/x-pack/plugins/code/public/components/query_bar/components/index.ts
new file mode 100644
index 0000000000000..0c6b5535c34e3
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { QueryBar } from './query_bar';
diff --git a/x-pack/plugins/code/public/components/query_bar/components/options.tsx b/x-pack/plugins/code/public/components/query_bar/components/options.tsx
new file mode 100644
index 0000000000000..f3970dcd6f073
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/options.tsx
@@ -0,0 +1,226 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiComboBox,
+ EuiFlexGroup,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+ EuiTextColor,
+ EuiTitle,
+ EuiNotificationBadge,
+} from '@elastic/eui';
+import { EuiIcon } from '@elastic/eui';
+import { unique } from 'lodash';
+import React, { Component } from 'react';
+import { Repository } from '../../../../model';
+import { SearchOptions as ISearchOptions } from '../../../actions';
+
+interface State {
+ isFlyoutOpen: boolean;
+ repoScope: Repository[];
+ query: string;
+ defaultRepoScopeOn: boolean;
+}
+
+interface Props {
+ repositorySearch: (p: { query: string }) => void;
+ saveSearchOptions: (searchOptions: ISearchOptions) => void;
+ repoSearchResults: any[];
+ searchLoading: boolean;
+ searchOptions: ISearchOptions;
+ defaultRepoOptions: Repository[];
+ defaultSearchScope?: Repository;
+}
+
+export class SearchOptions extends Component {
+ public state: State = {
+ query: '',
+ isFlyoutOpen: false,
+ repoScope: this.props.searchOptions.repoScope,
+ defaultRepoScopeOn: this.props.searchOptions.defaultRepoScopeOn,
+ };
+
+ componentDidUpdate(prevProps: Props) {
+ if (
+ this.props.searchOptions.defaultRepoScopeOn &&
+ !prevProps.searchOptions.defaultRepoScopeOn
+ ) {
+ this.setState({ defaultRepoScopeOn: this.props.searchOptions.defaultRepoScopeOn });
+ }
+ }
+
+ public applyAndClose = () => {
+ if (this.state.defaultRepoScopeOn && this.props.defaultSearchScope) {
+ this.props.saveSearchOptions({
+ repoScope: unique([...this.state.repoScope, this.props.defaultSearchScope], r => r.uri),
+ defaultRepoScopeOn: this.state.defaultRepoScopeOn,
+ });
+ } else {
+ this.props.saveSearchOptions({
+ repoScope: this.state.repoScope,
+ defaultRepoScopeOn: this.state.defaultRepoScopeOn,
+ });
+ }
+ this.setState({ isFlyoutOpen: false });
+ };
+
+ public removeRepoScope = (r: string) => () => {
+ this.setState(prevState => {
+ const nextState: any = {
+ repoScope: prevState.repoScope.filter(rs => rs.uri !== r),
+ };
+ if (this.props.defaultSearchScope && r === this.props.defaultSearchScope.uri) {
+ nextState.defaultRepoScopeOn = false;
+ }
+ return nextState;
+ });
+ };
+
+ public render() {
+ let optionsFlyout;
+ const repoScope =
+ this.state.defaultRepoScopeOn && this.props.defaultSearchScope
+ ? unique([...this.state.repoScope, this.props.defaultSearchScope], r => r.uri)
+ : this.state.repoScope;
+ if (this.state.isFlyoutOpen) {
+ const selectedRepos = repoScope.map(r => {
+ return (
+
+
+
+
+
+ {r.org}/
+ {r.name}
+
+
+
+
+
+
+
+ );
+ });
+
+ optionsFlyout = (
+
+
+
+
+
+ {repoScope.length}
+
+
+ {' '}
+ Search Filters{' '}
+
+
+
+
+
+
+ Repo Scope
+
+ Add indexed repos to your search scope
+
+ ({
+ label: repo.name,
+ }))
+ : this.props.defaultRepoOptions.map(repo => ({
+ label: repo.name,
+ }))
+ }
+ selectedOptions={[]}
+ isLoading={this.props.searchLoading}
+ onChange={this.onRepoChange}
+ onSearchChange={this.onRepoSearchChange}
+ isClearable={true}
+ />
+
+ {selectedRepos}
+
+
+
+ Apply and Close
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {repoScope.length}
+
+ Search Filters
+
+
+ {optionsFlyout}
+
+ );
+ }
+
+ private onRepoSearchChange = (searchValue: string) => {
+ this.setState({ query: searchValue });
+ if (searchValue) {
+ this.props.repositorySearch({
+ query: searchValue,
+ });
+ }
+ };
+
+ private onRepoChange = (repos: any) => {
+ this.setState(prevState => ({
+ repoScope: unique([
+ ...prevState.repoScope,
+ ...repos.map((r: any) =>
+ [...this.props.repoSearchResults, ...this.props.defaultRepoOptions].find(
+ rs => rs.name === r.label
+ )
+ ),
+ ]),
+ }));
+ };
+
+ private toggleOptionsFlyout = () => {
+ this.setState({
+ isFlyoutOpen: !this.state.isFlyoutOpen,
+ });
+ };
+
+ private closeOptionsFlyout = () => {
+ this.setState({
+ isFlyoutOpen: false,
+ repoScope: this.props.searchOptions.repoScope,
+ defaultRepoScopeOn: this.props.searchOptions.defaultRepoScopeOn,
+ });
+ };
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/components/query_bar.test.tsx b/x-pack/plugins/code/public/components/query_bar/components/query_bar.test.tsx
new file mode 100644
index 0000000000000..4e79d80689025
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/query_bar.test.tsx
@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { mount } from 'enzyme';
+import toJson from 'enzyme-to-json';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import sinon from 'sinon';
+
+import { SearchScope } from '../../../../model';
+import { AutocompleteSuggestionType } from '../suggestions';
+import props from './__fixtures__/props.json';
+import { CodeQueryBar } from './query_bar';
+
+// Injest a mock random function to fixiate the output for generating component id.
+const mockMath = Object.create(global.Math);
+mockMath.random = () => 0.5;
+global.Math = mockMath;
+
+test('render correctly with empty query string', () => {
+ const emptyFn = () => {
+ return;
+ };
+ const queryBarComp = mount(
+
+ );
+ expect(toJson(queryBarComp)).toMatchSnapshot();
+});
+
+test('render correctly with input query string changed', done => {
+ const emptyFn = () => {
+ return;
+ };
+
+ const emptyAsyncFn = (query: string): Promise => {
+ return Promise.resolve();
+ };
+
+ const fileSuggestionsSpy = sinon.fake.returns(
+ Promise.resolve(props[AutocompleteSuggestionType.FILE])
+ );
+ const symbolSuggestionsSpy = sinon.fake.returns(
+ Promise.resolve(props[AutocompleteSuggestionType.SYMBOL])
+ );
+ const repoSuggestionsSpy = sinon.fake.returns(
+ Promise.resolve(props[AutocompleteSuggestionType.REPOSITORY])
+ );
+
+ const mockFileSuggestionsProvider = {
+ getSuggestions: emptyAsyncFn,
+ };
+ mockFileSuggestionsProvider.getSuggestions = fileSuggestionsSpy;
+ const mockSymbolSuggestionsProvider = {
+ getSuggestions: emptyAsyncFn,
+ };
+ mockSymbolSuggestionsProvider.getSuggestions = symbolSuggestionsSpy;
+ const mockRepositorySuggestionsProvider = {
+ getSuggestions: emptyAsyncFn,
+ };
+ mockRepositorySuggestionsProvider.getSuggestions = repoSuggestionsSpy;
+
+ const submitSpy = sinon.spy();
+
+ const queryBarComp = mount(
+
+
+
+ );
+
+ // Input 'mockquery' in the query bar.
+ queryBarComp
+ .find('input[type="text"]')
+ .at(0)
+ .simulate('change', { target: { value: 'mockquery' } });
+
+ // Wait for 101ms to make sure the getSuggestions has been triggered.
+ setTimeout(() => {
+ expect(toJson(queryBarComp)).toMatchSnapshot();
+ expect(fileSuggestionsSpy.calledOnce).toBeTruthy();
+ expect(symbolSuggestionsSpy.calledOnce).toBeTruthy();
+ expect(repoSuggestionsSpy.calledOnce).toBeTruthy();
+
+ // Hit enter
+ queryBarComp
+ .find('input[type="text"]')
+ .at(0)
+ .simulate('keyDown', { keyCode: 13, key: 'Enter', metaKey: true });
+ expect(submitSpy.calledOnce).toBeTruthy();
+
+ done();
+ }, 1000);
+});
diff --git a/x-pack/plugins/code/public/components/query_bar/components/query_bar.tsx b/x-pack/plugins/code/public/components/query_bar/components/query_bar.tsx
new file mode 100644
index 0000000000000..d2495d8702f0a
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/query_bar.tsx
@@ -0,0 +1,515 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { debounce, isEqual } from 'lodash';
+import React, { Component } from 'react';
+
+import { EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiOutsideClickDetector } from '@elastic/eui';
+import { connect } from 'react-redux';
+import {
+ saveSearchOptions,
+ SearchOptions as ISearchOptions,
+ searchReposForScope,
+} from '../../../actions';
+import { matchPairs } from '../lib/match_pairs';
+import { SuggestionsComponent } from './typeahead/suggestions_component';
+
+import { SearchScope, Repository } from '../../../../model';
+import { SearchScopePlaceholderText } from '../../../common/types';
+import { RootState } from '../../../reducers';
+import {
+ AutocompleteSuggestion,
+ AutocompleteSuggestionGroup,
+ SuggestionsProvider,
+} from '../suggestions';
+import { SearchOptions } from './options';
+import { ScopeSelector } from './scope_selector';
+
+const KEY_CODES = {
+ LEFT: 37,
+ UP: 38,
+ RIGHT: 39,
+ DOWN: 40,
+ ENTER: 13,
+ ESC: 27,
+ TAB: 9,
+ HOME: 36,
+ END: 35,
+};
+
+interface Props {
+ query: string;
+ onSubmit: (query: string) => void;
+ onSelect: (item: AutocompleteSuggestion) => void;
+ disableAutoFocus?: boolean;
+ appName: string;
+ suggestionProviders: SuggestionsProvider[];
+ repositorySearch: (p: { query: string }) => void;
+ saveSearchOptions: (searchOptions: ISearchOptions) => void;
+ enableSubmitWhenOptionsChanged: boolean;
+ onSearchScopeChanged: (s: SearchScope) => void;
+ repoSearchResults: any[];
+ searchLoading: boolean;
+ searchScope: SearchScope;
+ searchOptions: ISearchOptions;
+ defaultRepoOptions: Repository[];
+ currentRepository?: Repository;
+}
+
+interface State {
+ query: string;
+ inputIsPristine: boolean;
+ isSuggestionsVisible: boolean;
+ groupIndex: number | null;
+ itemIndex: number | null;
+ suggestionGroups: AutocompleteSuggestionGroup[];
+ currentProps?: Props;
+}
+
+export class CodeQueryBar extends Component {
+ public static getDerivedStateFromProps(nextProps: Props, prevState: State) {
+ if (isEqual(prevState.currentProps, nextProps)) {
+ return null;
+ }
+
+ const nextState: any = {
+ currentProps: nextProps,
+ };
+ if (nextProps.query !== prevState.query) {
+ nextState.query = nextProps.query;
+ }
+ return nextState;
+ }
+
+ /*
+ Keep the "draft" value in local state until the user actually submits the query. There are a couple advantages:
+
+ 1. Each app doesn't have to maintain its own "draft" value if it wants to put off updating the query in app state
+ until the user manually submits their changes. Most apps have watches on the query value in app state so we don't
+ want to trigger those on every keypress. Also, some apps (e.g. dashboard) already juggle multiple query values,
+ each with slightly different semantics and I'd rather not add yet another variable to the mix.
+
+ 2. Changes to the local component state won't trigger an Angular digest cycle. Triggering digest cycles on every
+ keypress has been a major source of performance issues for us in previous implementations of the query bar.
+ See https://github.com/elastic/kibana/issues/14086
+ */
+ public state = {
+ query: this.props.query,
+ inputIsPristine: true,
+ isSuggestionsVisible: false,
+ groupIndex: null,
+ itemIndex: null,
+ suggestionGroups: [],
+ showOptions: false,
+ };
+
+ public updateSuggestions = debounce(async () => {
+ const suggestionGroups = (await this.getSuggestions()) || [];
+ if (!this.componentIsUnmounting) {
+ this.setState({ suggestionGroups });
+ }
+ }, 100);
+
+ public inputRef: HTMLInputElement | null = null;
+
+ public optionFlyout: any | null = null;
+
+ private componentIsUnmounting = false;
+
+ public isDirty = () => {
+ return this.state.query !== this.props.query;
+ };
+
+ public loadMore = () => {
+ // TODO(mengwei): Add action for load more.
+ };
+
+ public incrementIndex = (currGroupIndex: number, currItemIndex: number) => {
+ let nextItemIndex = currItemIndex + 1;
+
+ if (currGroupIndex === null) {
+ currGroupIndex = 0;
+ }
+ let nextGroupIndex = currGroupIndex;
+
+ const group: AutocompleteSuggestionGroup = this.state.suggestionGroups[currGroupIndex];
+ if (currItemIndex === null || nextItemIndex >= group.suggestions.length) {
+ nextItemIndex = 0;
+ nextGroupIndex = currGroupIndex + 1;
+ if (nextGroupIndex >= this.state.suggestionGroups.length) {
+ nextGroupIndex = 0;
+ }
+ }
+
+ this.setState({
+ groupIndex: nextGroupIndex,
+ itemIndex: nextItemIndex,
+ });
+ };
+
+ public decrementIndex = (currGroupIndex: number, currItemIndex: number) => {
+ let prevItemIndex = currItemIndex - 1;
+
+ if (currGroupIndex === null) {
+ currGroupIndex = this.state.suggestionGroups.length - 1;
+ }
+ let prevGroupIndex = currGroupIndex;
+
+ if (currItemIndex === null || prevItemIndex < 0) {
+ prevGroupIndex = currGroupIndex - 1;
+ if (prevGroupIndex < 0) {
+ prevGroupIndex = this.state.suggestionGroups.length - 1;
+ }
+ const group: AutocompleteSuggestionGroup = this.state.suggestionGroups[prevGroupIndex];
+ prevItemIndex = group.suggestions.length - 1;
+ }
+
+ this.setState({
+ groupIndex: prevGroupIndex,
+ itemIndex: prevItemIndex,
+ });
+ };
+
+ public getSuggestions = async () => {
+ if (!this.inputRef) {
+ return;
+ }
+
+ const { query } = this.state;
+ if (query.length === 0) {
+ return [];
+ }
+
+ if (!this.props.suggestionProviders || this.props.suggestionProviders.length === 0) {
+ return [];
+ }
+
+ const { selectionStart, selectionEnd } = this.inputRef;
+ if (selectionStart === null || selectionEnd === null) {
+ return;
+ }
+
+ const res = await Promise.all(
+ this.props.suggestionProviders.map((provider: SuggestionsProvider) => {
+ return provider.getSuggestions(
+ query,
+ this.props.searchScope,
+ this.props.searchOptions.repoScope.map(repo => repo.uri)
+ );
+ })
+ );
+
+ return res.filter((group: AutocompleteSuggestionGroup) => group.suggestions.length > 0);
+ };
+
+ public selectSuggestion = (item: AutocompleteSuggestion) => {
+ if (!this.inputRef) {
+ return;
+ }
+
+ const { selectionStart, selectionEnd } = this.inputRef;
+ if (selectionStart === null || selectionEnd === null) {
+ return;
+ }
+
+ this.setState(
+ {
+ query: '',
+ groupIndex: null,
+ itemIndex: null,
+ isSuggestionsVisible: false,
+ },
+ () => {
+ if (item) {
+ this.props.onSelect(item);
+ }
+ }
+ );
+ };
+
+ public onOutsideClick = () => {
+ this.setState({ isSuggestionsVisible: false, groupIndex: null, itemIndex: null });
+ };
+
+ public onClickInput = (event: React.MouseEvent) => {
+ if (event.target instanceof HTMLInputElement) {
+ this.onInputChange(event.target.value);
+ }
+ };
+
+ public onClickSubmitButton = (event: React.MouseEvent) => {
+ this.onSubmit(() => event.preventDefault());
+ };
+
+ public onClickSuggestion = (suggestion: AutocompleteSuggestion) => {
+ if (!this.inputRef) {
+ return;
+ }
+ this.selectSuggestion(suggestion);
+ this.inputRef.focus();
+ };
+
+ public onMouseEnterSuggestion = (groupIndex: number, itemIndex: number) => {
+ this.setState({ groupIndex, itemIndex });
+ };
+
+ public onInputChange = (value: string) => {
+ const hasValue = Boolean(value.trim());
+
+ this.setState({
+ query: value,
+ inputIsPristine: false,
+ isSuggestionsVisible: hasValue,
+ groupIndex: null,
+ itemIndex: null,
+ });
+ };
+
+ public onChange = (event: React.ChangeEvent) => {
+ this.updateSuggestions();
+ this.onInputChange(event.target.value);
+ };
+
+ public onKeyUp = (event: React.KeyboardEvent) => {
+ if ([KEY_CODES.LEFT, KEY_CODES.RIGHT, KEY_CODES.HOME, KEY_CODES.END].includes(event.keyCode)) {
+ this.setState({ isSuggestionsVisible: true });
+ if (event.target instanceof HTMLInputElement) {
+ this.onInputChange(event.target.value);
+ }
+ }
+ };
+
+ public onKeyDown = (event: React.KeyboardEvent) => {
+ if (event.target instanceof HTMLInputElement) {
+ const { isSuggestionsVisible, groupIndex, itemIndex } = this.state;
+ const preventDefault = event.preventDefault.bind(event);
+ const { target, key, metaKey } = event;
+ const { value, selectionStart, selectionEnd } = target;
+ const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => {
+ this.setState(
+ {
+ query,
+ },
+ () => {
+ target.setSelectionRange(newSelectionStart, newSelectionEnd);
+ }
+ );
+ };
+
+ switch (event.keyCode) {
+ case KEY_CODES.DOWN:
+ event.preventDefault();
+ if (isSuggestionsVisible && groupIndex !== null && itemIndex !== null) {
+ this.incrementIndex(groupIndex, itemIndex);
+ } else {
+ this.setState({ isSuggestionsVisible: true, groupIndex: 0, itemIndex: 0 });
+ }
+ break;
+ case KEY_CODES.UP:
+ event.preventDefault();
+ if (isSuggestionsVisible && groupIndex !== null && itemIndex !== null) {
+ this.decrementIndex(groupIndex, itemIndex);
+ } else {
+ const lastGroupIndex = this.state.suggestionGroups.length - 1;
+ const group: AutocompleteSuggestionGroup = this.state.suggestionGroups[lastGroupIndex];
+ if (group !== null) {
+ const lastItemIndex = group.suggestions.length - 1;
+ this.setState({
+ isSuggestionsVisible: true,
+ groupIndex: lastGroupIndex,
+ itemIndex: lastItemIndex,
+ });
+ }
+ }
+ break;
+ case KEY_CODES.ENTER:
+ event.preventDefault();
+ if (
+ isSuggestionsVisible &&
+ groupIndex !== null &&
+ itemIndex !== null &&
+ this.state.suggestionGroups[groupIndex]
+ ) {
+ const group: AutocompleteSuggestionGroup = this.state.suggestionGroups[groupIndex];
+ this.selectSuggestion(group.suggestions[itemIndex]);
+ } else {
+ this.onSubmit(() => event.preventDefault());
+ }
+ break;
+ case KEY_CODES.ESC:
+ event.preventDefault();
+ this.setState({ isSuggestionsVisible: false, groupIndex: null, itemIndex: null });
+ break;
+ case KEY_CODES.TAB:
+ this.setState({ isSuggestionsVisible: false, groupIndex: null, itemIndex: null });
+ break;
+ default:
+ if (selectionStart !== null && selectionEnd !== null) {
+ matchPairs({
+ value,
+ selectionStart,
+ selectionEnd,
+ key,
+ metaKey,
+ updateQuery,
+ preventDefault,
+ });
+ }
+
+ break;
+ }
+ }
+ };
+
+ public onSubmit = (preventDefault?: () => void) => {
+ if (preventDefault) {
+ preventDefault();
+ }
+
+ this.props.onSubmit(this.state.query);
+ this.setState({ isSuggestionsVisible: false });
+ };
+
+ public componentDidMount() {
+ this.updateSuggestions();
+ }
+
+ public componentDidUpdate(prevProps: Props) {
+ if (prevProps.query !== this.props.query) {
+ this.updateSuggestions();
+ }
+
+ // When search options (e.g. repository scopes) change,
+ // submit the search query again to refresh the search result.
+ if (
+ this.props.enableSubmitWhenOptionsChanged &&
+ !_.isEqual(prevProps.searchOptions, this.props.searchOptions)
+ ) {
+ this.onSubmit();
+ }
+ }
+
+ public componentWillUnmount() {
+ this.updateSuggestions.cancel();
+ this.componentIsUnmounting = true;
+ }
+
+ public focusInput() {
+ if (this.inputRef) {
+ this.inputRef.focus();
+ }
+ }
+
+ public toggleOptionsFlyout() {
+ if (this.optionFlyout) {
+ this.optionFlyout.toggleOptionsFlyout();
+ }
+ }
+
+ public render() {
+ const inputRef = (node: HTMLInputElement | null) => {
+ if (node) {
+ this.inputRef = node;
+ }
+ };
+ const activeDescendant = this.state.isSuggestionsVisible
+ ? `suggestion-${this.state.groupIndex}-${this.state.itemIndex}`
+ : '';
+ return (
+
+
+
+
+
+
+ {/* position:relative required on container so the suggestions appear under the query bar*/}
+
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ repoSearchResults: state.search.scopeSearchResults.repositories,
+ searchLoading: state.search.isScopeSearchLoading,
+ searchScope: state.search.scope,
+ searchOptions: state.search.searchOptions,
+ defaultRepoOptions: state.repository.repositories.slice(0, 5),
+ currentRepository: state.repository.currentRepository,
+});
+
+const mapDispatchToProps = {
+ repositorySearch: searchReposForScope,
+ saveSearchOptions,
+};
+
+export const QueryBar = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+ null,
+ { withRef: true }
+)(CodeQueryBar);
diff --git a/x-pack/plugins/code/public/components/query_bar/components/scope_selector.tsx b/x-pack/plugins/code/public/components/query_bar/components/scope_selector.tsx
new file mode 100644
index 0000000000000..052a56680292b
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/scope_selector.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiIcon,
+ // @ts-ignore
+ EuiSuperSelect,
+ EuiText,
+} from '@elastic/eui';
+import React, { Component } from 'react';
+
+import { SearchScope } from '../../../../model';
+import { SearchScopeText } from '../../../common/types';
+import { pxToRem } from '../../../style/variables';
+
+interface Props {
+ scope: SearchScope;
+ onScopeChanged: (s: SearchScope) => void;
+}
+
+export class ScopeSelector extends Component {
+ public scopeOptions = [
+ {
+ value: SearchScope.DEFAULT,
+ inputDisplay: (
+
+
+ {SearchScopeText[SearchScope.DEFAULT]}
+
+
+ ),
+ },
+ {
+ value: SearchScope.SYMBOL,
+ inputDisplay: (
+
+ {SearchScopeText[SearchScope.SYMBOL]}
+
+ ),
+ },
+ {
+ value: SearchScope.REPOSITORY,
+ inputDisplay: (
+
+ {SearchScopeText[SearchScope.REPOSITORY]}
+
+ ),
+ },
+ {
+ value: SearchScope.FILE,
+ inputDisplay: (
+
+ {SearchScopeText[SearchScope.FILE]}
+
+ ),
+ },
+ ];
+
+ public render() {
+ return (
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap b/x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap
new file mode 100644
index 0000000000000..daa1c1e2bf9f6
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap
@@ -0,0 +1,201 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`render file item 1`] = `
+
+
+
+
+
+
+ src/foo/
+
+ bar
+
+ .java
+
+
+
+ This is a file
+
+
+
+
+
+`;
+
+exports[`render repository item 1`] = `
+
+
+
+
+
+
+ elastic/
+
+ kibana
+
+
+
+
+
+
+
+
+`;
+
+exports[`render symbol item 1`] = `
+ src/foo/bar.java",
+ "end": 10,
+ "selectUrl": "http://github.com/elastic/elasticsearch/src/foo/bar.java",
+ "start": 1,
+ "text": "java.lang.String",
+ "tokenType": "tokenClass",
+ }
+ }
+>
+
+
+
+
+
+
+ java.lang.
+
+ String
+
+
+
+
+ elastic/elasticsearch > src/foo/bar.java
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap b/x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap
new file mode 100644
index 0000000000000..1488a046833e8
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap
@@ -0,0 +1,639 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`render empty suggestions component 1`] = `
+
+`;
+
+exports[`render full suggestions component 1`] = `
+
+
+ src/foo/bar.java",
+ "end": 10,
+ "selectUrl": "http://github.com/elastic/elasticsearch/src/foo/bar.java",
+ "start": 1,
+ "text": "java.lang.String",
+ "tokenType": "tokenClass",
+ },
+ ],
+ "total": 1,
+ "type": "symbol",
+ },
+ Object {
+ "hasMore": false,
+ "suggestions": Array [
+ Object {
+ "description": "This is a file",
+ "end": 10,
+ "selectUrl": "http://github.com/elastic/elasticsearch/src/foo/bar.java",
+ "start": 1,
+ "text": "src/foo/bar.java",
+ "tokenType": "",
+ },
+ ],
+ "total": 1,
+ "type": "file",
+ },
+ Object {
+ "hasMore": true,
+ "suggestions": Array [
+ Object {
+ "description": "",
+ "end": 10,
+ "selectUrl": "http://github.com/elastic/kibana",
+ "start": 1,
+ "text": "elastic/kibana",
+ "tokenType": "",
+ },
+ Object {
+ "description": "",
+ "end": 10,
+ "selectUrl": "http://github.com/elastic/elasticsearch",
+ "start": 1,
+ "text": "elastic/elasticsearch",
+ "tokenType": "",
+ },
+ ],
+ "total": 2,
+ "type": "repository",
+ },
+ ]
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Symbols
+
+
+
+
+
+ 1
+ Result
+
+
+
+
src/foo/bar.java",
+ "end": 10,
+ "selectUrl": "http://github.com/elastic/elasticsearch/src/foo/bar.java",
+ "start": 1,
+ "text": "java.lang.String",
+ "tokenType": "tokenClass",
+ }
+ }
+ >
+
+
+
+
+
+
+ java.lang.
+
+ String
+
+
+
+
+ elastic/elasticsearch > src/foo/bar.java
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src/foo/bar.java
+
+
+ This is a file
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 2
+ Result
+ s
+
+
+
+
+
+
+
+
+
+
+
+ elastic/elasticsearch
+
+
+
+
+
+
+
+
+
+
+
+ Press ⮐ Return for Full Text Search
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.test.tsx b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.test.tsx
new file mode 100644
index 0000000000000..d0e9a24849503
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.test.tsx
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { mount } from 'enzyme';
+import toJson from 'enzyme-to-json';
+import React from 'react';
+import sinon from 'sinon';
+
+import props from '../__fixtures__/props.json';
+import { SuggestionComponent } from './suggestion_component';
+
+test('render file item', () => {
+ const emptyFn = () => {
+ return;
+ };
+ const suggestionItem = mount(
+
+ );
+ expect(toJson(suggestionItem)).toMatchSnapshot();
+});
+
+test('render symbol item', () => {
+ const emptyFn = () => {
+ return;
+ };
+ const suggestionItem = mount(
+
+ );
+ expect(toJson(suggestionItem)).toMatchSnapshot();
+});
+
+test('render repository item', () => {
+ const emptyFn = () => {
+ return;
+ };
+ const suggestionItem = mount(
+
+ );
+ expect(toJson(suggestionItem)).toMatchSnapshot();
+});
diff --git a/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.tsx b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.tsx
new file mode 100644
index 0000000000000..884950906185e
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.tsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiToken, IconType } from '@elastic/eui';
+import React, { SFC } from 'react';
+import { AutocompleteSuggestion } from '../..';
+
+interface Props {
+ query: string;
+ onClick: (suggestion: AutocompleteSuggestion) => void;
+ onMouseEnter: () => void;
+ selected: boolean;
+ suggestion: AutocompleteSuggestion;
+ innerRef: (node: HTMLDivElement) => void;
+ ariaId: string;
+}
+
+export const SuggestionComponent: SFC = props => {
+ const click = () => props.onClick(props.suggestion);
+
+ // An util function to help highlight the substring which matches the query.
+ const renderMatchingText = (text: string) => {
+ // Match the text with query in case sensitive mode first.
+ let index = text.indexOf(props.query);
+ if (index < 0) {
+ // Fall back with case insensitive mode first.
+ index = text.toLowerCase().indexOf(props.query.toLowerCase());
+ }
+ if (index >= 0) {
+ const prefix = text.substring(0, index);
+ const highlight = text.substring(index, index + props.query.length);
+ const surfix = text.substring(index + props.query.length);
+ return (
+
+ {prefix}
+ {highlight}
+ {surfix}
+
+ );
+ } else {
+ return text;
+ }
+ };
+
+ const icon = props.suggestion.tokenType ? (
+
+
+
+ ) : null;
+
+ return (
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
+
+
+ {icon}
+
+
+ {renderMatchingText(props.suggestion.text)}
+
+
{props.suggestion.description}
+
+
+
+ );
+};
diff --git a/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.test.tsx b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.test.tsx
new file mode 100644
index 0000000000000..04de9a244f0cb
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.test.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { mount } from 'enzyme';
+import toJson from 'enzyme-to-json';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+import props from '../__fixtures__/props.json';
+import { SuggestionsComponent } from './suggestions_component';
+
+test('render empty suggestions component', () => {
+ const emptyFn = () => {
+ return;
+ };
+ const suggestionItem = mount(
+
+ );
+ expect(toJson(suggestionItem)).toMatchSnapshot();
+});
+
+test('render full suggestions component', () => {
+ const emptyFn = () => {
+ return;
+ };
+ const suggestionItem = mount(
+
+
+
+ );
+ expect(toJson(suggestionItem)).toMatchSnapshot();
+});
diff --git a/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx
new file mode 100644
index 0000000000000..0125aa0113cfa
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx
@@ -0,0 +1,200 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiText, EuiToken, IconType } from '@elastic/eui';
+import { isEmpty } from 'lodash';
+import React, { Component } from 'react';
+import { Link } from 'react-router-dom';
+import url from 'url';
+
+import {
+ AutocompleteSuggestion,
+ AutocompleteSuggestionGroup,
+ AutocompleteSuggestionType,
+} from '../..';
+import { SuggestionComponent } from './suggestion_component';
+
+interface Props {
+ query: string;
+ groupIndex: number | null;
+ itemIndex: number | null;
+ onClick: (suggestion: AutocompleteSuggestion) => void;
+ onMouseEnter: (groupIndex: number, itemIndex: number) => void;
+ show: boolean;
+ suggestionGroups: AutocompleteSuggestionGroup[];
+ loadMore: () => void;
+}
+
+export class SuggestionsComponent extends Component {
+ private childNodes: HTMLDivElement[] = [];
+ private parentNode: HTMLDivElement | null = null;
+
+ private viewMoreUrl() {
+ return url.format({
+ pathname: '/search',
+ query: {
+ q: this.props.query,
+ },
+ });
+ }
+
+ public render() {
+ if (!this.props.show || isEmpty(this.props.suggestionGroups)) {
+ return null;
+ }
+
+ return (
+
+
+
+ {this.renderSuggestionGroups()}
+
+
+ Press ⮐ Return for Full Text Search
+
+
+
+
+
+ );
+ }
+
+ public componentDidUpdate(prevProps: Props) {
+ if (
+ prevProps.groupIndex !== this.props.groupIndex ||
+ prevProps.itemIndex !== this.props.itemIndex
+ ) {
+ this.scrollIntoView();
+ }
+ }
+
+ private renderSuggestionGroups() {
+ return this.props.suggestionGroups
+ .filter((group: AutocompleteSuggestionGroup) => group.suggestions.length > 0)
+ .map((group: AutocompleteSuggestionGroup, groupIndex: number) => {
+ const { suggestions, total, type, hasMore } = group;
+ const suggestionComps = suggestions.map(
+ (suggestion: AutocompleteSuggestion, itemIndex: number) => {
+ const innerRef = (node: any) => (this.childNodes[itemIndex] = node);
+ const mouseEnter = () => this.props.onMouseEnter(groupIndex, itemIndex);
+ const isSelected =
+ groupIndex === this.props.groupIndex && itemIndex === this.props.itemIndex;
+ return (
+
+ );
+ }
+ );
+
+ const groupHeader = (
+
+
+
+
+ {this.getGroupTitle(group.type)}
+
+
+
+ {total} Result
+ {total === 1 ? '' : 's'}
+
+
+ );
+
+ const viewMore = (
+
+ View More
+
+ );
+
+ return (
+ (this.parentNode = node)}
+ onScroll={this.handleScroll}
+ key={`${type}-suggestions`}
+ >
+ {groupHeader}
+ {suggestionComps}
+ {hasMore ? viewMore : null}
+
+ );
+ });
+ }
+
+ private getGroupTokenType(type: AutocompleteSuggestionType): string {
+ switch (type) {
+ case AutocompleteSuggestionType.FILE:
+ return 'tokenFile';
+ case AutocompleteSuggestionType.REPOSITORY:
+ return 'tokenRepo';
+ case AutocompleteSuggestionType.SYMBOL:
+ return 'tokenSymbol';
+ }
+ }
+
+ private getGroupTitle(type: AutocompleteSuggestionType): string {
+ switch (type) {
+ case AutocompleteSuggestionType.FILE:
+ return 'Files';
+ case AutocompleteSuggestionType.REPOSITORY:
+ return 'Repos';
+ case AutocompleteSuggestionType.SYMBOL:
+ return 'Symbols';
+ }
+ }
+
+ private scrollIntoView = () => {
+ if (this.props.groupIndex === null || this.props.itemIndex === null) {
+ return;
+ }
+ const parent = this.parentNode;
+ const child = this.childNodes[this.props.itemIndex];
+
+ if (this.props.groupIndex == null || this.props.itemIndex === null || !parent || !child) {
+ return;
+ }
+
+ const scrollTop = Math.max(
+ Math.min(parent.scrollTop, child.offsetTop),
+ child.offsetTop + child.offsetHeight - parent.offsetHeight
+ );
+
+ parent.scrollTop = scrollTop;
+ };
+
+ private handleScroll = () => {
+ if (!this.props.loadMore || !this.parentNode) {
+ return;
+ }
+
+ const position = this.parentNode.scrollTop + this.parentNode.offsetHeight;
+ const height = this.parentNode.scrollHeight;
+ const remaining = height - position;
+ const margin = 50;
+
+ if (!height || !position) {
+ return;
+ }
+ if (remaining <= margin) {
+ this.props.loadMore();
+ }
+ };
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/index.ts b/x-pack/plugins/code/public/components/query_bar/index.ts
new file mode 100644
index 0000000000000..8cd9383123677
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ * This QueryBar component is forked from the QueryBar implemented in kibana/ui/public/query_bar
+ * with simplifications to fulfill Code's feature request.
+ *
+ * The styles has been migrated to styled-components instead of css for any new components brought
+ * by Code. For shared components/styles, you can find the classes in the scss files in
+ * kibana/ui/public/query_bar
+ */
+
+export * from './components';
+export * from './suggestions';
diff --git a/x-pack/plugins/code/public/components/query_bar/lib/match_pairs.ts b/x-pack/plugins/code/public/components/query_bar/lib/match_pairs.ts
new file mode 100644
index 0000000000000..8919f97dd5a28
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/lib/match_pairs.ts
@@ -0,0 +1,133 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * This helper automatically handles matching pairs.
+ * Specifically, it does the following:
+ *
+ * 1. If the key is a closer, and the character in front of the cursor is the
+ * same, simply move the cursor forward.
+ * 2. If the key is an opener, insert the opener at the beginning of the
+ * selection, and the closer at the end of the selection, and move the
+ * selection forward.
+ * 3. If the backspace is hit, and the characters before and after the cursor
+ * are a pair, remove both characters and move the cursor backward.
+ */
+
+const pairs = ['()', '[]', '{}', `''`, '""'];
+const openers = pairs.map(pair => pair[0]);
+const closers = pairs.map(pair => pair[1]);
+
+interface MatchPairsOptions {
+ value: string;
+ selectionStart: number;
+ selectionEnd: number;
+ key: string;
+ metaKey: boolean;
+ updateQuery: (query: string, selectionStart: number, selectionEnd: number) => void;
+ preventDefault: () => void;
+}
+
+export function matchPairs({
+ value,
+ selectionStart,
+ selectionEnd,
+ key,
+ metaKey,
+ updateQuery,
+ preventDefault,
+}: MatchPairsOptions) {
+ if (shouldMoveCursorForward(key, value, selectionStart, selectionEnd)) {
+ preventDefault();
+ updateQuery(value, selectionStart + 1, selectionEnd + 1);
+ } else if (shouldInsertMatchingCloser(key, value, selectionStart, selectionEnd)) {
+ preventDefault();
+ const newValue =
+ value.substr(0, selectionStart) +
+ key +
+ value.substring(selectionStart, selectionEnd) +
+ closers[openers.indexOf(key)] +
+ value.substr(selectionEnd);
+ updateQuery(newValue, selectionStart + 1, selectionEnd + 1);
+ } else if (shouldRemovePair(key, metaKey, value, selectionStart, selectionEnd)) {
+ preventDefault();
+ const newValue = value.substr(0, selectionEnd - 1) + value.substr(selectionEnd + 1);
+ updateQuery(newValue, selectionStart - 1, selectionEnd - 1);
+ }
+}
+
+function shouldMoveCursorForward(
+ key: string,
+ value: string,
+ selectionStart: number,
+ selectionEnd: number
+) {
+ if (!closers.includes(key)) {
+ return false;
+ }
+
+ // Never move selection forward for multi-character selections
+ if (selectionStart !== selectionEnd) {
+ return false;
+ }
+
+ // Move selection forward if the key is the same as the closer in front of the selection
+ return value.charAt(selectionEnd) === key;
+}
+
+function shouldInsertMatchingCloser(
+ key: string,
+ value: string,
+ selectionStart: number,
+ selectionEnd: number
+) {
+ if (!openers.includes(key)) {
+ return false;
+ }
+
+ // Always insert for multi-character selections
+ if (selectionStart !== selectionEnd) {
+ return true;
+ }
+
+ const precedingCharacter = value.charAt(selectionStart - 1);
+ const followingCharacter = value.charAt(selectionStart + 1);
+
+ // Don't insert if the preceding character is a backslash
+ if (precedingCharacter === '\\') {
+ return false;
+ }
+
+ // Don't insert if it's a quote and the either of the preceding/following characters is alphanumeric
+ return !(
+ ['"', `'`].includes(key) &&
+ (isAlphanumeric(precedingCharacter) || isAlphanumeric(followingCharacter))
+ );
+}
+
+function shouldRemovePair(
+ key: string,
+ metaKey: boolean,
+ value: string,
+ selectionStart: number,
+ selectionEnd: number
+) {
+ if (key !== 'Backspace' || metaKey) {
+ return false;
+ }
+
+ // Never remove for multi-character selections
+ if (selectionStart !== selectionEnd) {
+ return false;
+ }
+
+ // Remove if the preceding/following characters are a pair
+ return pairs.includes(value.substr(selectionEnd - 1, 2));
+}
+
+function isAlphanumeric(value = '') {
+ return value.match(/[a-zA-Z0-9_]/);
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts b/x-pack/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts
new file mode 100644
index 0000000000000..2e49e769f9632
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { kfetch } from 'ui/kfetch';
+
+import {
+ AbstractSuggestionsProvider,
+ AutocompleteSuggestionGroup,
+ AutocompleteSuggestionType,
+} from '.';
+import { toRepoNameWithOrg } from '../../../../common/uri_util';
+import { SearchResultItem, SearchScope } from '../../../../model';
+
+export class FileSuggestionsProvider extends AbstractSuggestionsProvider {
+ protected matchSearchScope(scope: SearchScope): boolean {
+ return scope === SearchScope.DEFAULT || scope === SearchScope.FILE;
+ }
+
+ protected async fetchSuggestions(
+ query: string,
+ repoScope?: string[]
+ ): Promise {
+ try {
+ const queryParams: { q: string; repoScope?: string } = { q: query };
+ if (repoScope && repoScope.length > 0) {
+ queryParams.repoScope = repoScope.join(',');
+ }
+ const res = await kfetch({
+ pathname: `/api/code/suggestions/doc`,
+ method: 'get',
+ query: queryParams,
+ });
+ const suggestions = Array.from(res.results as SearchResultItem[])
+ .slice(0, this.MAX_SUGGESTIONS_PER_GROUP)
+ .map((doc: SearchResultItem) => {
+ return {
+ description: toRepoNameWithOrg(doc.uri),
+ end: 10,
+ start: 1,
+ text: doc.filePath,
+ tokenType: '',
+ selectUrl: `/${doc.uri}/blob/HEAD/${doc.filePath}`,
+ };
+ });
+ return {
+ type: AutocompleteSuggestionType.FILE,
+ total: res.total,
+ hasMore: res.total > this.MAX_SUGGESTIONS_PER_GROUP,
+ suggestions,
+ };
+ } catch (error) {
+ return {
+ type: AutocompleteSuggestionType.FILE,
+ total: 0,
+ hasMore: false,
+ suggestions: [],
+ };
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/suggestions/index.ts b/x-pack/plugins/code/public/components/query_bar/suggestions/index.ts
new file mode 100644
index 0000000000000..85d172aca5d79
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/suggestions/index.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './suggestions_provider';
+export * from './symbol_suggestions_provider';
+export * from './file_suggestions_provider';
+export * from './repository_suggestions_provider';
+
+export enum AutocompleteSuggestionType {
+ SYMBOL = 'symbol',
+ FILE = 'file',
+ REPOSITORY = 'repository',
+}
+
+export interface AutocompleteSuggestion {
+ description?: string;
+ end: number;
+ start: number;
+ text: string;
+ tokenType: string;
+ selectUrl: string;
+}
+
+export interface AutocompleteSuggestionGroup {
+ type: AutocompleteSuggestionType;
+ total: number;
+ hasMore: boolean;
+ suggestions: AutocompleteSuggestion[];
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts b/x-pack/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts
new file mode 100644
index 0000000000000..5c5a4129c1b49
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { kfetch } from 'ui/kfetch';
+
+import {
+ AbstractSuggestionsProvider,
+ AutocompleteSuggestionGroup,
+ AutocompleteSuggestionType,
+} from '.';
+import { toRepoNameWithOrg } from '../../../../common/uri_util';
+import { Repository, SearchScope } from '../../../../model';
+
+export class RepositorySuggestionsProvider extends AbstractSuggestionsProvider {
+ protected matchSearchScope(scope: SearchScope): boolean {
+ return scope === SearchScope.DEFAULT || scope === SearchScope.REPOSITORY;
+ }
+
+ protected async fetchSuggestions(
+ query: string,
+ repoScope?: string[]
+ ): Promise {
+ try {
+ const queryParams: { q: string; repoScope?: string } = { q: query };
+ if (repoScope && repoScope.length > 0) {
+ queryParams.repoScope = repoScope.join(',');
+ }
+ const res = await kfetch({
+ pathname: `/api/code/suggestions/repo`,
+ method: 'get',
+ query: queryParams,
+ });
+ const suggestions = Array.from(res.repositories as Repository[])
+ .slice(0, this.MAX_SUGGESTIONS_PER_GROUP)
+ .map((repo: Repository) => {
+ return {
+ description: repo.url,
+ end: 10,
+ start: 1,
+ text: toRepoNameWithOrg(repo.uri),
+ tokenType: '',
+ selectUrl: `/${repo.uri}`,
+ };
+ });
+ return {
+ type: AutocompleteSuggestionType.REPOSITORY,
+ total: res.total,
+ hasMore: res.total > this.MAX_SUGGESTIONS_PER_GROUP,
+ suggestions,
+ };
+ } catch (error) {
+ return {
+ type: AutocompleteSuggestionType.REPOSITORY,
+ total: 0,
+ hasMore: false,
+ suggestions: [],
+ };
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/suggestions/suggestions_provider.ts b/x-pack/plugins/code/public/components/query_bar/suggestions/suggestions_provider.ts
new file mode 100644
index 0000000000000..ef9846bd4aed9
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/suggestions/suggestions_provider.ts
@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AutocompleteSuggestionGroup, AutocompleteSuggestionType } from '.';
+import { SearchScope } from '../../../../model';
+
+export interface SuggestionsProvider {
+ getSuggestions(
+ query: string,
+ scope: SearchScope,
+ repoScope?: string[]
+ ): Promise;
+}
+
+export abstract class AbstractSuggestionsProvider implements SuggestionsProvider {
+ protected MAX_SUGGESTIONS_PER_GROUP = 5;
+
+ public async getSuggestions(
+ query: string,
+ scope: SearchScope,
+ repoScope?: string[]
+ ): Promise {
+ if (this.matchSearchScope(scope)) {
+ return await this.fetchSuggestions(query, repoScope);
+ } else {
+ // This is an abstract class. Do nothing here. You should override this.
+ return new Promise((resolve, reject) => {
+ resolve({
+ type: AutocompleteSuggestionType.SYMBOL,
+ total: 0,
+ hasMore: false,
+ suggestions: [],
+ });
+ });
+ }
+ }
+
+ protected async fetchSuggestions(
+ query: string,
+ repoScope?: string[]
+ ): Promise {
+ // This is an abstract class. Do nothing here. You should override this.
+ return new Promise((resolve, reject) => {
+ resolve({
+ type: AutocompleteSuggestionType.SYMBOL,
+ total: 0,
+ hasMore: false,
+ suggestions: [],
+ });
+ });
+ }
+
+ protected matchSearchScope(scope: SearchScope): boolean {
+ return true;
+ }
+}
diff --git a/x-pack/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts b/x-pack/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts
new file mode 100644
index 0000000000000..c40297f6bb38b
--- /dev/null
+++ b/x-pack/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts
@@ -0,0 +1,144 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DetailSymbolInformation } from '@elastic/lsp-extension';
+import { kfetch } from 'ui/kfetch';
+import { Location } from 'vscode-languageserver';
+
+import {
+ AbstractSuggestionsProvider,
+ AutocompleteSuggestion,
+ AutocompleteSuggestionGroup,
+ AutocompleteSuggestionType,
+} from '.';
+import { RepositoryUtils } from '../../../../common/repository_utils';
+import { parseLspUrl, toRepoNameWithOrg } from '../../../../common/uri_util';
+import { SearchScope } from '../../../../model';
+
+export class SymbolSuggestionsProvider extends AbstractSuggestionsProvider {
+ protected matchSearchScope(scope: SearchScope): boolean {
+ return scope === SearchScope.DEFAULT || scope === SearchScope.SYMBOL;
+ }
+
+ protected async fetchSuggestions(
+ query: string,
+ repoScope?: string[]
+ ): Promise {
+ try {
+ const queryParams: { q: string; repoScope?: string } = { q: query };
+ if (repoScope && repoScope.length > 0) {
+ queryParams.repoScope = repoScope.join(',');
+ }
+ const res = await kfetch({
+ pathname: `/api/code/suggestions/symbol`,
+ method: 'get',
+ query: queryParams,
+ });
+ const suggestions = Array.from(res.symbols as DetailSymbolInformation[])
+ .slice(0, this.MAX_SUGGESTIONS_PER_GROUP)
+ .map((symbol: DetailSymbolInformation) => {
+ return {
+ description: this.getSymbolDescription(symbol.symbolInformation.location),
+ end: 10,
+ start: 1,
+ text: symbol.qname,
+ tokenType: this.symbolKindToTokenClass(symbol.symbolInformation.kind),
+ selectUrl: this.getSymbolLinkUrl(symbol.symbolInformation.location),
+ };
+ });
+ return {
+ type: AutocompleteSuggestionType.SYMBOL,
+ total: res.total,
+ hasMore: res.total > this.MAX_SUGGESTIONS_PER_GROUP,
+ suggestions: suggestions as AutocompleteSuggestion[],
+ };
+ } catch (error) {
+ return {
+ type: AutocompleteSuggestionType.SYMBOL,
+ total: 0,
+ hasMore: false,
+ suggestions: [],
+ };
+ }
+ }
+
+ private getSymbolDescription(location: Location) {
+ try {
+ const { repoUri, file } = parseLspUrl(location.uri);
+ const repoName = toRepoNameWithOrg(repoUri);
+ return `${repoName} > ${file}`;
+ } catch (error) {
+ return '';
+ }
+ }
+
+ private getSymbolLinkUrl(location: Location) {
+ try {
+ return RepositoryUtils.locationToUrl(location);
+ } catch (error) {
+ return '';
+ }
+ }
+
+ private symbolKindToTokenClass(kind: number): string {
+ switch (kind) {
+ case 1: // File
+ return 'tokenFile';
+ case 2: // Module
+ return 'tokenModule';
+ case 3: // Namespace
+ return 'tokenNamespace';
+ case 4: // Package
+ return 'tokenPackage';
+ case 5: // Class
+ return 'tokenClass';
+ case 6: // Method
+ return 'tokenMethod';
+ case 7: // Property
+ return 'tokenProperty';
+ case 8: // Field
+ return 'tokenField';
+ case 9: // Constructor
+ return 'tokenConstant';
+ case 10: // Enum
+ return 'tokenEnum';
+ case 11: // Interface
+ return 'tokenInterface';
+ case 12: // Function
+ return 'tokenFunction';
+ case 13: // Variable
+ return 'tokenVariable';
+ case 14: // Constant
+ return 'tokenConstant';
+ case 15: // String
+ return 'tokenString';
+ case 16: // Number
+ return 'tokenNumber';
+ case 17: // Bollean
+ return 'tokenBoolean';
+ case 18: // Array
+ return 'tokenArray';
+ case 19: // Object
+ return 'tokenObject';
+ case 20: // Key
+ return 'tokenKey';
+ case 21: // Null
+ return 'tokenNull';
+ case 22: // EnumMember
+ return 'tokenEnumMember';
+ case 23: // Struct
+ return 'tokenStruct';
+ case 24: // Event
+ return 'tokenEvent';
+ case 25: // Operator
+ return 'tokenOperator';
+ case 26: // TypeParameter
+ return 'tokenParameter';
+ default:
+ return 'tokenElement';
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/components/route.ts b/x-pack/plugins/code/public/components/route.ts
new file mode 100644
index 0000000000000..0c4892b0188dd
--- /dev/null
+++ b/x-pack/plugins/code/public/components/route.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { connect } from 'react-redux';
+import { Route as ReactRoute, RouteProps } from 'react-router-dom';
+import { Match, routeChange } from '../actions';
+import { decodeRevisionString } from '../utils/url';
+
+interface Props extends RouteProps {
+ routeChange: (match: Match) => void;
+}
+class CSRoute extends ReactRoute {
+ // eslint-disable-next-line @typescript-eslint/camelcase
+ public UNSAFE_componentWillMount() {
+ if (this.state.match && this.state.match.params && this.state.match.params.revision) {
+ this.state.match.params.revision = decodeRevisionString(this.state.match.params.revision);
+ }
+ this.props.routeChange({ ...this.state.match, location: this.props.location });
+ }
+
+ public componentDidUpdate() {
+ if (this.state.match && this.state.match.params && this.state.match.params.revision) {
+ this.state.match.params.revision = decodeRevisionString(this.state.match.params.revision);
+ }
+ this.props.routeChange({ ...this.state.match, location: this.props.location });
+ }
+}
+
+export const Route = connect(
+ null,
+ { routeChange }
+)(CSRoute);
diff --git a/x-pack/plugins/code/public/components/routes.ts b/x-pack/plugins/code/public/components/routes.ts
new file mode 100644
index 0000000000000..873e1b7f5e30a
--- /dev/null
+++ b/x-pack/plugins/code/public/components/routes.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PathTypes } from '../common/types';
+
+export const ROOT = '/';
+export const SETUP = '/setup-guide';
+const pathTypes = `:pathType(${PathTypes.blob}|${PathTypes.tree}|${PathTypes.blame}|${
+ PathTypes.commits
+})`;
+export const MAIN = `/:resource/:org/:repo/${pathTypes}/:revision/:path*:goto(!.*)?`;
+export const DIFF = '/:resource/:org/:repo/commit/:commitId';
+export const REPO = `/:resource/:org/:repo`;
+export const MAIN_ROOT = `/:resource/:org/:repo/${pathTypes}/:revision`;
+export const ADMIN = '/admin';
+export const SEARCH = '/search';
diff --git a/x-pack/plugins/code/public/components/search_page/code_result.tsx b/x-pack/plugins/code/public/components/search_page/code_result.tsx
new file mode 100644
index 0000000000000..3a72dfd0a954d
--- /dev/null
+++ b/x-pack/plugins/code/public/components/search_page/code_result.tsx
@@ -0,0 +1,91 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
+import { IPosition } from 'monaco-editor';
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import { RepositoryUtils } from '../../../common/repository_utils';
+import { history } from '../../utils/url';
+import { CodeBlock } from '../codeblock/codeblock';
+
+interface Props {
+ results: any[];
+}
+
+export class CodeResult extends React.PureComponent {
+ public render() {
+ return this.props.results.map(item => {
+ const { uri, filePath, hits, compositeContent } = item;
+ const { content, lineMapping, ranges } = compositeContent;
+ const repoLinkUrl = `/${uri}/tree/HEAD/`;
+ const fileLinkUrl = `/${uri}/blob/HEAD/${filePath}`;
+ const key = `${uri}${filePath}`;
+ const lineMappingFunc = (l: number) => {
+ return lineMapping[l - 1];
+ };
+ return (
+
+
+
+
+
+
+ {RepositoryUtils.orgNameFromUri(uri)}/
+
+
+
+
+ {RepositoryUtils.repoNameFromUri(uri)}
+
+
+
+
+
+
+
+ {hits}
+
+
+ hits from
+
+ {filePath}
+
+
+
+
+
+ );
+ });
+ }
+
+ private onCodeClick(lineNumbers: string[], fileUrl: string, pos: IPosition) {
+ const line = parseInt(lineNumbers[pos.lineNumber - 1], 10);
+ if (!isNaN(line)) {
+ history.push(`${fileUrl}!L${line}:0`);
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/components/search_page/empty_placeholder.tsx b/x-pack/plugins/code/public/components/search_page/empty_placeholder.tsx
new file mode 100644
index 0000000000000..4655efa79310e
--- /dev/null
+++ b/x-pack/plugins/code/public/components/search_page/empty_placeholder.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
+import React from 'react';
+
+export const EmptyPlaceholder = (props: any) => {
+ return (
+
+
+
+
+ "{props.query}"
+
+
+
+
+ Hmmm... we looked for that, but couldn’t find anything.
+
+
+
+
+
+ You can search for something else or modify your search settings.
+
+
+
+ {
+ if (props.toggleOptionsFlyout) {
+ props.toggleOptionsFlyout();
+ }
+ }}
+ >
+ Modify your search settings
+
+
+
+ );
+};
diff --git a/x-pack/plugins/code/public/components/search_page/pagination.tsx b/x-pack/plugins/code/public/components/search_page/pagination.tsx
new file mode 100644
index 0000000000000..bc3ee4384b9b9
--- /dev/null
+++ b/x-pack/plugins/code/public/components/search_page/pagination.tsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiPagination } from '@elastic/eui';
+import querystring from 'querystring';
+import React from 'react';
+import url from 'url';
+
+import { history } from '../../utils/url';
+
+interface Props {
+ query: string;
+ totalPage: number;
+ currentPage: number;
+}
+
+export class Pagination extends React.PureComponent {
+ public onPageClicked = (page: number) => {
+ const { query } = this.props;
+ const queries = querystring.parse(history.location.search.replace('?', ''));
+ history.push(
+ url.format({
+ pathname: '/search',
+ query: {
+ ...queries,
+ q: query,
+ p: page + 1,
+ },
+ })
+ );
+ };
+
+ public render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/search_page/scope_tabs.tsx b/x-pack/plugins/code/public/components/search_page/scope_tabs.tsx
new file mode 100644
index 0000000000000..cffa3e9e679aa
--- /dev/null
+++ b/x-pack/plugins/code/public/components/search_page/scope_tabs.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiTab, EuiTabs } from '@elastic/eui';
+import querystring from 'querystring';
+import React from 'react';
+import url from 'url';
+
+import { SearchScope } from '../../../model';
+import { history } from '../../utils/url';
+
+interface Props {
+ query: string;
+ scope: SearchScope;
+}
+
+export class ScopeTabs extends React.PureComponent {
+ public onTabClicked = (scope: SearchScope) => {
+ return () => {
+ const { query } = this.props;
+ const queries = querystring.parse(history.location.search.replace('?', ''));
+ history.push(
+ url.format({
+ pathname: '/search',
+ query: {
+ ...queries,
+ q: query,
+ scope,
+ },
+ })
+ );
+ };
+ };
+
+ public render() {
+ return (
+
+
+
+ Code
+
+
+ Repository
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/search_page/search.tsx b/x-pack/plugins/code/public/components/search_page/search.tsx
new file mode 100644
index 0000000000000..a96b0b20ed3ba
--- /dev/null
+++ b/x-pack/plugins/code/public/components/search_page/search.tsx
@@ -0,0 +1,240 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
+import querystring from 'querystring';
+import React from 'react';
+import { connect } from 'react-redux';
+import chrome from 'ui/chrome';
+import url from 'url';
+
+import { DocumentSearchResult, SearchScope } from '../../../model';
+import { changeSearchScope, SearchOptions } from '../../actions';
+import { RootState } from '../../reducers';
+import { history } from '../../utils/url';
+import { ProjectItem } from '../admin_page/project_item';
+import { ShortcutsProvider } from '../shortcuts';
+import { CodeResult } from './code_result';
+import { EmptyPlaceholder } from './empty_placeholder';
+import { Pagination } from './pagination';
+import { SearchBar } from './search_bar';
+import { SideBar } from './side_bar';
+
+interface Props {
+ searchOptions: SearchOptions;
+ query: string;
+ scope: SearchScope;
+ page?: number;
+ languages?: Set;
+ repositories?: Set;
+ isLoading: boolean;
+ error?: Error;
+ documentSearchResults?: DocumentSearchResult;
+ repositorySearchResults?: any;
+ onSearchScopeChanged: (s: SearchScope) => void;
+}
+
+interface State {
+ uri: string;
+}
+
+class SearchPage extends React.PureComponent {
+ public state = {
+ uri: '',
+ };
+
+ public searchBar: any = null;
+
+ public componentDidMount() {
+ chrome.breadcrumbs.push({ text: `Search` });
+ }
+
+ public componentWillUnmount() {
+ chrome.breadcrumbs.pop();
+ }
+
+ public onLanguageFilterToggled = (lang: string) => {
+ const { languages, repositories, query, page } = this.props;
+ let tempLangs: Set = new Set();
+ if (languages && languages.has(lang)) {
+ // Remove this language filter
+ tempLangs = new Set(languages);
+ tempLangs.delete(lang);
+ } else {
+ // Add this language filter
+ tempLangs = languages ? new Set(languages) : new Set();
+ tempLangs.add(lang);
+ }
+ const queries = querystring.parse(history.location.search.replace('?', ''));
+ return () => {
+ history.push(
+ url.format({
+ pathname: '/search',
+ query: {
+ ...queries,
+ q: query,
+ p: page,
+ langs: Array.from(tempLangs).join(','),
+ repos: repositories ? Array.from(repositories).join(',') : undefined,
+ },
+ })
+ );
+ };
+ };
+
+ public onRepositoryFilterToggled = (repo: string) => {
+ const { languages, repositories, query } = this.props;
+ let tempRepos: Set = new Set();
+ if (repositories && repositories.has(repo)) {
+ // Remove this repository filter
+ tempRepos = new Set(repositories);
+ tempRepos.delete(repo);
+ } else {
+ // Add this language filter
+ tempRepos = repositories ? new Set(repositories) : new Set();
+ tempRepos.add(repo);
+ }
+ const queries = querystring.parse(history.location.search.replace('?', ''));
+ return () => {
+ history.push(
+ url.format({
+ pathname: '/search',
+ query: {
+ ...queries,
+ q: query,
+ p: 1,
+ langs: languages ? Array.from(languages).join(',') : undefined,
+ repos: Array.from(tempRepos).join(','),
+ },
+ })
+ );
+ };
+ };
+
+ public render() {
+ const {
+ query,
+ scope,
+ documentSearchResults,
+ languages,
+ repositories,
+ repositorySearchResults,
+ } = this.props;
+
+ let mainComp = (
+ {
+ this.searchBar.toggleOptionsFlyout();
+ }}
+ />
+ );
+ let repoStats: any[] = [];
+ let languageStats: any[] = [];
+ if (
+ scope === SearchScope.REPOSITORY &&
+ repositorySearchResults &&
+ repositorySearchResults.total > 0
+ ) {
+ const { repositories: repos, from, total } = repositorySearchResults;
+ const resultComps =
+ repos &&
+ repos.map((repo: any) => (
+
+
+
+ ));
+ const to = from + repos.length;
+ const statsComp = (
+
+
+ Showing {total > 0 ? from : 0} - {to} of {total} results.
+
+
+ );
+ mainComp = (
+
+ {statsComp}
+
+
{resultComps}
+
+ );
+ } else if (
+ scope === SearchScope.DEFAULT &&
+ documentSearchResults &&
+ (documentSearchResults.total > 0 || languages!.size > 0 || repositories!.size > 0)
+ ) {
+ const { stats, results } = documentSearchResults!;
+ const { total, from, to, page, totalPage } = stats!;
+ languageStats = stats!.languageStats;
+ repoStats = stats!.repoStats;
+ const statsComp = (
+
+
+ Showing {total > 0 ? from : 0} - {to} of {total} results.
+
+
+ );
+ mainComp = (
+
+ {statsComp}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ r.uri)}
+ query={this.props.query}
+ onSearchScopeChanged={this.props.onSearchScopeChanged}
+ ref={element => (this.searchBar = element)}
+ />
+ {mainComp}
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ ...state.search,
+});
+
+const mapDispatchToProps = {
+ onSearchScopeChanged: changeSearchScope,
+};
+
+export const Search = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(SearchPage);
diff --git a/x-pack/plugins/code/public/components/search_page/search_bar.tsx b/x-pack/plugins/code/public/components/search_page/search_bar.tsx
new file mode 100644
index 0000000000000..6515ddf2d0b7f
--- /dev/null
+++ b/x-pack/plugins/code/public/components/search_page/search_bar.tsx
@@ -0,0 +1,119 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import querystring from 'querystring';
+import React from 'react';
+import url from 'url';
+
+import { SearchScope } from '../../../model';
+import { SearchScopeText } from '../../common/types';
+import { history } from '../../utils/url';
+import { Shortcut } from '../shortcuts';
+
+import {
+ AutocompleteSuggestion,
+ FileSuggestionsProvider,
+ QueryBar,
+ RepositorySuggestionsProvider,
+ SymbolSuggestionsProvider,
+} from '../query_bar';
+
+interface Props {
+ query: string;
+ onSearchScopeChanged: (s: SearchScope) => void;
+ repoScope: string[];
+}
+
+export class SearchBar extends React.PureComponent {
+ public queryBar: any = null;
+
+ public onSearchChanged = (query: string) => {
+ // Update the url and push to history as well.
+ const queries = querystring.parse(history.location.search.replace('?', ''));
+ history.push(
+ url.format({
+ pathname: '/search',
+ query: {
+ ...queries,
+ q: query,
+ repoScope: this.props.repoScope,
+ },
+ })
+ );
+ };
+
+ public toggleOptionsFlyout() {
+ if (this.queryBar) {
+ this.queryBar.toggleOptionsFlyout();
+ }
+ }
+
+ public render() {
+ const onSubmit = (q: string) => {
+ this.onSearchChanged(q);
+ };
+
+ const onSelect = (item: AutocompleteSuggestion) => {
+ history.push(item.selectUrl);
+ };
+
+ const suggestionProviders = [
+ new SymbolSuggestionsProvider(),
+ new FileSuggestionsProvider(),
+ new RepositorySuggestionsProvider(),
+ ];
+
+ return (
+
+ {
+ this.props.onSearchScopeChanged(SearchScope.REPOSITORY);
+ if (this.queryBar) {
+ this.queryBar.focusInput();
+ }
+ }}
+ />
+ {
+ this.props.onSearchScopeChanged(SearchScope.SYMBOL);
+ if (this.queryBar) {
+ this.queryBar.focusInput();
+ }
+ }}
+ />
+ {
+ this.props.onSearchScopeChanged(SearchScope.DEFAULT);
+ if (this.queryBar) {
+ this.queryBar.focusInput();
+ }
+ }}
+ />
+ {
+ if (instance) {
+ // @ts-ignore
+ this.queryBar = instance.getWrappedInstance();
+ }
+ }}
+ />
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/search_page/side_bar.tsx b/x-pack/plugins/code/public/components/search_page/side_bar.tsx
new file mode 100644
index 0000000000000..617315e1e12b3
--- /dev/null
+++ b/x-pack/plugins/code/public/components/search_page/side_bar.tsx
@@ -0,0 +1,150 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiFacetButton,
+ EuiFacetGroup,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiTitle,
+ EuiToken,
+} from '@elastic/eui';
+import React from 'react';
+
+import { RepositoryUtils } from '../../../common/repository_utils';
+import { SearchScope } from '../../../model';
+import { ScopeTabs } from './scope_tabs';
+
+interface Props {
+ query: string;
+ scope: SearchScope;
+ languages?: Set;
+ repositories?: Set;
+ langFacets: any[];
+ repoFacets: any[];
+ onLanguageFilterToggled: (lang: string) => any;
+ onRepositoryFilterToggled: (repo: string) => any;
+}
+
+export class SideBar extends React.PureComponent {
+ public render() {
+ const { languages, langFacets, repoFacets, repositories } = this.props;
+ const repoStatsComp = repoFacets.map((item, index) => {
+ if (!!repositories && repositories.has(item.name)) {
+ return (
+ {
+ /* nothing */
+ }}
+ >
+ {RepositoryUtils.repoNameFromUri(item.name)}
+
+ );
+ } else {
+ return (
+ {
+ /* nothing */
+ }}
+ >
+ {RepositoryUtils.repoNameFromUri(item.name)}
+
+ );
+ }
+ });
+
+ const langStatsComp = langFacets.map((item, index) => {
+ if (languages && languages.has(item.name)) {
+ return (
+ {
+ /* nothing */
+ }}
+ >
+ {item.name}
+
+ );
+ } else {
+ return (
+ {
+ /* nothing */
+ }}
+ >
+ {item.name}
+
+ );
+ }
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ Repositories
+
+
+
+ {repoStatsComp}
+
+
+
+
+
+
+
+ Languages
+
+
+
+
+ {langStatsComp}
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/shared/icons.tsx b/x-pack/plugins/code/public/components/shared/icons.tsx
new file mode 100644
index 0000000000000..0268ad049c7f2
--- /dev/null
+++ b/x-pack/plugins/code/public/components/shared/icons.tsx
@@ -0,0 +1,333 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+export const TypeScriptIcon = () => (
+
+
+
+
+
+
+);
+
+export const JavaIcon = () => (
+
+
+
+
+
+
+);
+
+export const GoIcon = () => (
+
+
+
+
+
+
+
+);
+
+export const BinaryFileIcon = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const ErrorIcon = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/x-pack/plugins/code/public/components/shortcuts/index.tsx b/x-pack/plugins/code/public/components/shortcuts/index.tsx
new file mode 100644
index 0000000000000..a56629a10c415
--- /dev/null
+++ b/x-pack/plugins/code/public/components/shortcuts/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { Shortcut, OS, HotKey, Modifier } from './shortcut';
+export { ShortcutsProvider } from './shortcuts_provider';
diff --git a/x-pack/plugins/code/public/components/shortcuts/shortcut.tsx b/x-pack/plugins/code/public/components/shortcuts/shortcut.tsx
new file mode 100644
index 0000000000000..7e5afc8b25e47
--- /dev/null
+++ b/x-pack/plugins/code/public/components/shortcuts/shortcut.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { registerShortcut, unregisterShortcut } from '../../actions';
+
+export enum OS {
+ win,
+ mac,
+ linux,
+}
+
+export enum Modifier {
+ ctrl,
+ meta,
+ alt,
+ shift,
+}
+
+export interface HotKey {
+ key: string;
+ modifier: Map;
+ help: string;
+ onPress?: (dispatch: any) => void;
+}
+
+interface Props {
+ keyCode: string;
+ help: string;
+ onPress?: (dispatch: any) => void;
+ winModifier?: Modifier[];
+ macModifier?: Modifier[];
+ linuxModifier?: Modifier[];
+ registerShortcut(hotKey: HotKey): void;
+ unregisterShortcut(hotKey: HotKey): void;
+}
+
+class ShortcutsComponent extends React.Component {
+ private readonly hotKey: HotKey;
+ constructor(props: Props, context: any) {
+ super(props, context);
+ this.hotKey = {
+ key: props.keyCode,
+ help: props.help,
+ onPress: props.onPress,
+ modifier: new Map(),
+ };
+ if (props.winModifier) {
+ this.hotKey.modifier.set(OS.win, props.winModifier);
+ }
+ if (props.macModifier) {
+ this.hotKey.modifier.set(OS.mac, props.macModifier);
+ }
+ if (props.linuxModifier) {
+ this.hotKey.modifier.set(OS.linux, props.linuxModifier);
+ }
+ }
+
+ public componentDidMount(): void {
+ this.props.registerShortcut(this.hotKey);
+ }
+
+ public componentWillUnmount(): void {
+ this.props.unregisterShortcut(this.hotKey);
+ }
+
+ public render(): React.ReactNode {
+ return null;
+ }
+}
+
+const mapDispatchToProps = {
+ registerShortcut,
+ unregisterShortcut,
+};
+
+export const Shortcut = connect(
+ null,
+ mapDispatchToProps
+)(ShortcutsComponent);
diff --git a/x-pack/plugins/code/public/components/shortcuts/shortcuts_provider.tsx b/x-pack/plugins/code/public/components/shortcuts/shortcuts_provider.tsx
new file mode 100644
index 0000000000000..229e0a50dda35
--- /dev/null
+++ b/x-pack/plugins/code/public/components/shortcuts/shortcuts_provider.tsx
@@ -0,0 +1,203 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ EuiButton,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiOverlayMask,
+} from '@elastic/eui';
+import React from 'react';
+import { connect } from 'react-redux';
+import { toggleHelp } from '../../actions';
+import { RootState } from '../../reducers';
+import { HotKey, Modifier, OS } from './shortcut';
+
+interface Props {
+ showHelp: boolean;
+ shortcuts: HotKey[];
+ dispatch(action: any): void;
+}
+
+class ShortcutsComponent extends React.Component {
+ private readonly os: OS;
+
+ constructor(props: Props) {
+ super(props);
+
+ if (navigator.appVersion.indexOf('Win') !== -1) {
+ this.os = OS.win;
+ } else if (navigator.appVersion.indexOf('Mac') !== -1) {
+ this.os = OS.mac;
+ } else {
+ this.os = OS.linux;
+ }
+ }
+
+ public componentDidMount(): void {
+ document.addEventListener('keydown', this.handleKeydown);
+ document.addEventListener('keypress', this.handleKeyPress);
+ }
+
+ public componentWillUnmount(): void {
+ document.removeEventListener('keydown', this.handleKeydown);
+ document.removeEventListener('keypress', this.handleKeyPress);
+ }
+
+ public render(): React.ReactNode {
+ return (
+
+ {this.props.showHelp && (
+
+
+
+ Keyboard Shortcuts
+
+
+ {this.renderShortcuts()}
+
+
+ Close
+
+
+
+
+ )}
+
+ );
+ }
+
+ private handleKeydown = (event: KeyboardEvent) => {
+ const target = event.target;
+ const key = event.key;
+ // @ts-ignore
+ if (target && target.tagName === 'INPUT') {
+ if (key === 'Escape') {
+ // @ts-ignore
+ target.blur();
+ }
+ }
+ };
+
+ private handleKeyPress = (event: KeyboardEvent) => {
+ const target = event.target;
+ const key = event.key;
+ // @ts-ignore
+ if (target && target.tagName === 'INPUT') {
+ return;
+ }
+
+ const isPressed = (s: HotKey) => {
+ if (s.modifier) {
+ const mods = s.modifier.get(this.os) || [];
+ for (const mod of mods) {
+ switch (mod) {
+ case Modifier.alt:
+ if (!event.altKey) {
+ return false;
+ }
+ break;
+ case Modifier.ctrl:
+ if (!event.ctrlKey) {
+ return false;
+ }
+ break;
+ case Modifier.meta:
+ if (!event.metaKey) {
+ return false;
+ }
+ break;
+ case Modifier.shift:
+ if (!event.shiftKey) {
+ return false;
+ }
+ break;
+ }
+ }
+ }
+ return key === s.key;
+ };
+
+ let isTriggered = false;
+ for (const shortcut of this.props.shortcuts) {
+ if (isPressed(shortcut) && shortcut.onPress) {
+ shortcut.onPress(this.props.dispatch);
+ isTriggered = true;
+ }
+ }
+ if (isTriggered) {
+ // Discard this input since it's been triggered already.
+ event.preventDefault();
+ }
+ };
+
+ private closeModal = () => {
+ this.props.dispatch(toggleHelp(false));
+ };
+
+ private showModifier(mod: Modifier): string {
+ switch (mod) {
+ case Modifier.meta:
+ if (this.os === OS.mac) {
+ return '⌘';
+ } else if (this.os === OS.win) {
+ return '⊞ Win';
+ } else {
+ return 'meta';
+ }
+
+ case Modifier.shift:
+ if (this.os === OS.mac) {
+ return '⇧';
+ } else {
+ return 'shift';
+ }
+ case Modifier.ctrl:
+ if (this.os === OS.mac) {
+ return '⌃';
+ } else {
+ return 'ctrl';
+ }
+ case Modifier.alt:
+ if (this.os === OS.mac) {
+ return '⌥';
+ } else {
+ return 'alt';
+ }
+ }
+ }
+
+ private renderShortcuts() {
+ return this.props.shortcuts.map((s, idx) => {
+ return (
+
+ {this.renderModifier(s)}
+ {s.key}
+ {s.help}
+
+ );
+ });
+ }
+
+ private renderModifier(hotKey: HotKey) {
+ if (hotKey.modifier) {
+ const modifiers = hotKey.modifier.get(this.os) || [];
+ return modifiers.map(m => {this.showModifier(m)}
);
+ } else {
+ return null;
+ }
+ }
+}
+
+const mapStateToProps = (state: RootState) => ({
+ shortcuts: state.shortcuts.shortcuts,
+ showHelp: state.shortcuts.showHelp,
+});
+
+export const ShortcutsProvider = connect(mapStateToProps)(ShortcutsComponent);
diff --git a/x-pack/plugins/code/public/components/symbol_tree/__test__/__fixtures__/props.ts b/x-pack/plugins/code/public/components/symbol_tree/__test__/__fixtures__/props.ts
new file mode 100644
index 0000000000000..e4cfbe1946e5d
--- /dev/null
+++ b/x-pack/plugins/code/public/components/symbol_tree/__test__/__fixtures__/props.ts
@@ -0,0 +1,140 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SymbolKind } from 'vscode-languageserver-types';
+import { SymbolWithMembers } from '../../../../reducers/symbol';
+
+export const props: { structureTree: SymbolWithMembers[] } = {
+ structureTree: [
+ {
+ name: '"stack-control"',
+ kind: SymbolKind.Module,
+ location: {
+ uri:
+ 'git://github.com/vmware/clarity/blob/master/src/clr-angular/data/stack-view/stack-control.ts',
+ range: { start: { line: 0, character: 0 }, end: { line: 27, character: 0 } },
+ },
+ path: '"stack-control"',
+ members: [
+ {
+ name: 'EventEmitter',
+ kind: SymbolKind.Variable,
+ location: {
+ uri:
+ 'git://github.com/vmware/clarity/blob/master/src/clr-angular/data/stack-view/stack-control.ts',
+ range: { start: { line: 9, character: 9 }, end: { line: 9, character: 21 } },
+ },
+ containerName: '"stack-control"',
+ path: '"stack-control"/EventEmitter',
+ },
+ {
+ name: 'ClrStackView',
+ kind: SymbolKind.Variable,
+ location: {
+ uri:
+ 'git://github.com/vmware/clarity/blob/master/src/clr-angular/data/stack-view/stack-control.ts',
+ range: { start: { line: 10, character: 9 }, end: { line: 10, character: 21 } },
+ },
+ containerName: '"stack-control"',
+ path: '"stack-control"/ClrStackView',
+ },
+ {
+ name: 'StackControl',
+ kind: SymbolKind.Class,
+ location: {
+ uri:
+ 'git://github.com/vmware/clarity/blob/master/src/clr-angular/data/stack-view/stack-control.ts',
+ range: { start: { line: 12, character: 0 }, end: { line: 26, character: 1 } },
+ },
+ containerName: '"stack-control"',
+ path: '"stack-control"/StackControl',
+ members: [
+ {
+ name: 'model',
+ kind: SymbolKind.Property,
+ location: {
+ uri:
+ 'git://github.com/vmware/clarity/blob/master/src/clr-angular/data/stack-view/stack-control.ts',
+ range: { start: { line: 13, character: 2 }, end: { line: 13, character: 13 } },
+ },
+ containerName: 'StackControl',
+ path: '"stack-control"/StackControl/model',
+ },
+ {
+ name: 'modelChange',
+ kind: SymbolKind.Property,
+ location: {
+ uri:
+ 'git://github.com/vmware/clarity/blob/master/src/clr-angular/data/stack-view/stack-control.ts',
+ range: { start: { line: 14, character: 2 }, end: { line: 14, character: 64 } },
+ },
+ containerName: 'StackControl',
+ path: '"stack-control"/StackControl/modelChange',
+ },
+ {
+ name: 'stackView',
+ kind: SymbolKind.Property,
+ location: {
+ uri:
+ 'git://github.com/vmware/clarity/blob/master/src/clr-angular/data/stack-view/stack-control.ts',
+ range: { start: { line: 16, character: 14 }, end: { line: 16, character: 47 } },
+ },
+ containerName: 'StackControl',
+ path: '"stack-control"/StackControl/stackView',
+ },
+ {
+ name: 'HashMap',
+ kind: SymbolKind.Class,
+ location: {
+ uri:
+ 'git://github.com/elastic/openjdkMirror/blob/master/jdk/src/share/classes/java/util/HashMap.java',
+ range: { start: { line: 136, character: 13 }, end: { line: 136, character: 20 } },
+ },
+ containerName: 'HashMap.java',
+ path: 'HashMap',
+ members: [
+ {
+ name: 'serialVersionUID',
+ kind: SymbolKind.Field,
+ location: {
+ uri:
+ 'git://github.com/elastic/openjdkMirror/blob/master/jdk/src/share/classes/java/util/HashMap.java',
+ range: {
+ start: { line: 139, character: 30 },
+ end: { line: 139, character: 46 },
+ },
+ },
+ containerName: 'HashMap',
+ path: 'HashMap/serialVersionUID',
+ },
+ ],
+ },
+ {
+ name: 'Unit',
+ kind: SymbolKind.Variable,
+ location: {
+ uri:
+ 'git://github.com/elastic/kibana/blob/master/packages/elastic-datemath/src/index.d.ts',
+ range: { start: { line: 20, character: 0 }, end: { line: 20, character: 66 } },
+ },
+ path: 'Unit',
+ },
+ {
+ name: 'datemath',
+ kind: SymbolKind.Constant,
+ location: {
+ uri:
+ 'git://github.com/elastic/kibana/blob/master/packages/elastic-datemath/src/index.d.ts',
+ range: { start: { line: 22, character: 14 }, end: { line: 47, character: 1 } },
+ },
+ path: 'datemath',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
diff --git a/x-pack/plugins/code/public/components/symbol_tree/__test__/__snapshots__/symbol_tree.test.tsx.snap b/x-pack/plugins/code/public/components/symbol_tree/__test__/__snapshots__/symbol_tree.test.tsx.snap
new file mode 100644
index 0000000000000..9fb8cfb2ac4cf
--- /dev/null
+++ b/x-pack/plugins/code/public/components/symbol_tree/__test__/__snapshots__/symbol_tree.test.tsx.snap
@@ -0,0 +1,712 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`render symbol tree correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ className="euiSideNavItemButton"
+ >
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/code/public/components/symbol_tree/__test__/symbol_tree.test.tsx b/x-pack/plugins/code/public/components/symbol_tree/__test__/symbol_tree.test.tsx
new file mode 100644
index 0000000000000..4d86c3d45cc93
--- /dev/null
+++ b/x-pack/plugins/code/public/components/symbol_tree/__test__/symbol_tree.test.tsx
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import renderer from 'react-test-renderer';
+import { mockFunction } from '../../../utils/test_utils';
+import { CodeSymbolTree } from '../code_symbol_tree';
+import { props } from './__fixtures__/props';
+
+test('render symbol tree correctly', () => {
+ const tree = renderer
+ .create(
+
+
+
+ )
+ .toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/x-pack/plugins/code/public/components/symbol_tree/code_symbol_tree.tsx b/x-pack/plugins/code/public/components/symbol_tree/code_symbol_tree.tsx
new file mode 100644
index 0000000000000..334de88083d5d
--- /dev/null
+++ b/x-pack/plugins/code/public/components/symbol_tree/code_symbol_tree.tsx
@@ -0,0 +1,145 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSideNav, EuiText, EuiToken } from '@elastic/eui';
+import { IconType } from '@elastic/eui';
+import React from 'react';
+import { Link } from 'react-router-dom';
+import url from 'url';
+import { Location, SymbolKind } from 'vscode-languageserver-types/lib/umd/main';
+
+import { RepositoryUtils } from '../../../common/repository_utils';
+import { EuiSideNavItem } from '../../common/types';
+import { SymbolWithMembers } from '../../reducers/symbol';
+
+interface Props {
+ structureTree: SymbolWithMembers[];
+ closedPaths: string[];
+ openSymbolPath: (p: string) => void;
+ closeSymbolPath: (p: string) => void;
+}
+
+const sortSymbol = (a: SymbolWithMembers, b: SymbolWithMembers) => {
+ const lineDiff = a.location.range.start.line - b.location.range.start.line;
+ if (lineDiff === 0) {
+ return a.location.range.start.character - b.location.range.start.character;
+ } else {
+ return lineDiff;
+ }
+};
+
+export class CodeSymbolTree extends React.PureComponent {
+ public state = { activePath: undefined };
+
+ public getClickHandler = (path: string) => () => {
+ this.setState({ activePath: path });
+ };
+
+ public getStructureTreeItemRenderer = (
+ location: Location,
+ name: string,
+ kind: SymbolKind,
+ isContainer: boolean = false,
+ forceOpen: boolean = false,
+ path: string = ''
+ ) => () => {
+ let tokenType = 'tokenFile';
+
+ // @ts-ignore
+ tokenType = `token${Object.keys(SymbolKind).find(k => SymbolKind[k] === kind)}`;
+ let bg = null;
+ if (this.state.activePath === path) {
+ bg =
;
+ }
+ return (
+
+
+ {isContainer &&
+ (forceOpen ? (
+ this.props.closeSymbolPath(path)}
+ />
+ ) : (
+ this.props.openSymbolPath(path)}
+ />
+ ))}
+
+
+
+
+
+ {name}
+
+
+
+
+
+ {bg}
+
+ );
+ };
+
+ public symbolsToSideNavItems = (symbolsWithMembers: SymbolWithMembers[]): EuiSideNavItem[] => {
+ return symbolsWithMembers.sort(sortSymbol).map((s: SymbolWithMembers, index: number) => {
+ const item: EuiSideNavItem = {
+ name: s.name,
+ id: `${s.name}_${index}`,
+ onClick: () => void 0,
+ };
+ if (s.members) {
+ item.forceOpen = !this.props.closedPaths.includes(s.path!);
+ if (item.forceOpen) {
+ item.items = this.symbolsToSideNavItems(s.members);
+ }
+ item.renderItem = this.getStructureTreeItemRenderer(
+ s.location,
+ s.name,
+ s.kind,
+ true,
+ item.forceOpen,
+ s.path
+ );
+ } else {
+ item.renderItem = this.getStructureTreeItemRenderer(
+ s.location,
+ s.name,
+ s.kind,
+ false,
+ false,
+ s.path
+ );
+ }
+ return item;
+ });
+ };
+
+ public render() {
+ const items = [
+ { name: '', id: '', items: this.symbolsToSideNavItems(this.props.structureTree) },
+ ];
+ return (
+
+
+
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/components/symbol_tree/symbol_tree.tsx b/x-pack/plugins/code/public/components/symbol_tree/symbol_tree.tsx
new file mode 100644
index 0000000000000..d39dd837d0ae7
--- /dev/null
+++ b/x-pack/plugins/code/public/components/symbol_tree/symbol_tree.tsx
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { connect } from 'react-redux';
+import { closeSymbolPath, openSymbolPath } from '../../actions';
+import { RootState } from '../../reducers';
+import { structureSelector } from '../../selectors';
+import { CodeSymbolTree } from './code_symbol_tree';
+
+const mapStateToProps = (state: RootState) => ({
+ structureTree: structureSelector(state),
+ closedPaths: state.symbol.closedPaths,
+});
+
+const mapDispatchToProps = {
+ openSymbolPath,
+ closeSymbolPath,
+};
+
+export const SymbolTree = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(CodeSymbolTree);
diff --git a/x-pack/plugins/code/public/index.scss b/x-pack/plugins/code/public/index.scss
new file mode 100644
index 0000000000000..090366342939d
--- /dev/null
+++ b/x-pack/plugins/code/public/index.scss
@@ -0,0 +1,19 @@
+@import 'src/legacy/ui/public/styles/_styling_constants';
+
+@import "./components/editor/references_panel.scss";
+@import "./monaco/override_monaco_styles.scss";
+@import "./components/diff_page/diff.scss";
+@import "./components/main/main.scss";
+
+// TODO: Cleanup everything above this line
+
+@import "./style/utilities";
+@import "./style/buttons";
+@import "./style/layout";
+@import "./style/sidebar";
+@import "./style/markdown";
+@import "./style/shortcuts";
+@import "./style/monaco";
+@import "./style/filetree";
+@import "./style/query_bar";
+@import "./style/filters";
diff --git a/x-pack/plugins/code/public/lib/documentation_links.ts b/x-pack/plugins/code/public/lib/documentation_links.ts
new file mode 100644
index 0000000000000..c750da3b064dc
--- /dev/null
+++ b/x-pack/plugins/code/public/lib/documentation_links.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links';
+
+// TODO make sure document links are right
+export const documentationLinks = {
+ code: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code.html`,
+ codeIntelligence: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code.html`,
+ gitFormat: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code.html`,
+};
diff --git a/x-pack/plugins/code/public/monaco/blame/blame_widget.ts b/x-pack/plugins/code/public/monaco/blame/blame_widget.ts
new file mode 100644
index 0000000000000..cf3b5993f21e3
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/blame/blame_widget.ts
@@ -0,0 +1,73 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor as Editor } from 'monaco-editor';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { GitBlame } from '../../../common/git_blame';
+import { Blame } from '../../components/main/blame';
+
+export class BlameWidget implements Editor.IContentWidget {
+ public allowEditorOverflow = true;
+
+ public suppressMouseDown = false;
+ private domNode: HTMLDivElement;
+ private containerNode: HTMLDivElement;
+
+ constructor(
+ readonly blame: GitBlame,
+ readonly isFirstLine: boolean,
+ readonly editor: Editor.IStandaloneCodeEditor
+ ) {
+ this.containerNode = document.createElement('div');
+ this.domNode = document.createElement('div');
+ this.containerNode.appendChild(this.domNode);
+ this.editor.onDidLayoutChange(() => this.update());
+ // this.editor.onDidScrollChange(e => this.update());
+ this.update();
+ // @ts-ignore
+ this.editor.addContentWidget(this);
+ this.editor.layoutContentWidget(this);
+ }
+
+ public destroy() {
+ this.editor.removeContentWidget(this);
+ }
+
+ public getDomNode(): HTMLElement {
+ return this.containerNode;
+ }
+
+ public getId(): string {
+ return 'blame_' + this.blame.startLine;
+ }
+
+ public getPosition(): Editor.IContentWidgetPosition {
+ return {
+ position: {
+ column: 0,
+ lineNumber: this.blame.startLine,
+ },
+ preference: [0],
+ };
+ }
+
+ private update() {
+ const { fontSize, lineHeight } = this.editor.getConfiguration().fontInfo;
+ this.domNode.style.position = 'relative';
+ this.domNode.style.left = '-332px';
+ this.domNode.style.width = '316px';
+ this.domNode.style.fontSize = `${fontSize}px`;
+ this.domNode.style.lineHeight = `${lineHeight}px`;
+ const element = React.createElement(
+ Blame,
+ { blame: this.blame, isFirstLine: this.isFirstLine },
+ null
+ );
+ // @ts-ignore
+ ReactDOM.render(element, this.domNode);
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/computer.ts b/x-pack/plugins/code/public/monaco/computer.ts
new file mode 100644
index 0000000000000..ef26b16849ba6
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/computer.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface AsyncTask {
+ promise(): Promise;
+ cancel(): void;
+}
+export interface Computer {
+ compute(): AsyncTask;
+ loadingMessage(): T;
+}
diff --git a/x-pack/plugins/code/public/monaco/content_widget.ts b/x-pack/plugins/code/public/monaco/content_widget.ts
new file mode 100644
index 0000000000000..01a653aec08e9
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/content_widget.ts
@@ -0,0 +1,140 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor as Editor } from 'monaco-editor';
+// @ts-ignore
+import { DomScrollableElement } from 'monaco-editor/esm/vs/base/browser/ui/scrollbar/scrollableElement';
+import { Disposable } from './disposable';
+import { monaco } from './monaco';
+
+export function toggleClass(node: HTMLElement, clazzName: string, toggle: boolean) {
+ node.classList.toggle(clazzName, toggle);
+}
+
+export abstract class ContentWidget extends Disposable implements Editor.IContentWidget {
+ protected get isVisible(): boolean {
+ return this.visible;
+ }
+
+ protected set isVisible(value: boolean) {
+ this.visible = value;
+ toggleClass(this.containerDomNode, 'hidden', !this.visible);
+ }
+ protected readonly containerDomNode: HTMLElement;
+ protected domNode: HTMLElement;
+ private readonly extraNode: HTMLDivElement;
+ private scrollbar: any;
+ private showAtPosition: Position | null;
+ private stoleFocus: boolean = false;
+ private visible: boolean;
+
+ protected constructor(readonly id: string, readonly editor: Editor.ICodeEditor) {
+ super();
+ this.containerDomNode = document.createElement('div');
+ this.domNode = document.createElement('div');
+ this.extraNode = document.createElement('div');
+ this.scrollbar = new DomScrollableElement(this.domNode, {});
+ this.disposables.push(this.scrollbar);
+ this.containerDomNode.appendChild(this.scrollbar.getDomNode());
+ this.containerDomNode.appendChild(this.extraNode);
+
+ this.visible = false;
+ this.editor.onDidLayoutChange(e => this.updateMaxHeight());
+ this.editor.onDidChangeModel(() => this.hide());
+ this.updateMaxHeight();
+ this.showAtPosition = null;
+ // @ts-ignore
+ this.editor.addContentWidget(this);
+ }
+
+ public getId(): string {
+ return this.id;
+ }
+
+ public getDomNode(): HTMLElement {
+ return this.containerDomNode;
+ }
+
+ public showAt(position: any, focus: boolean): void {
+ this.showAtPosition = position;
+ // @ts-ignore
+ this.editor.layoutContentWidget(this);
+ this.isVisible = true;
+ this.editor.render();
+ this.stoleFocus = focus;
+ if (focus) {
+ this.containerDomNode.focus();
+ }
+ }
+
+ public hide(): void {
+ if (!this.isVisible) {
+ return;
+ }
+
+ this.isVisible = false;
+ // @ts-ignore
+ this.editor.layoutContentWidget(this);
+ if (this.stoleFocus) {
+ this.editor.focus();
+ }
+ }
+
+ // @ts-ignore
+ public getPosition() {
+ const { ContentWidgetPositionPreference } = monaco.editor;
+ if (this.isVisible) {
+ return {
+ position: this.showAtPosition!,
+ preference: [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW],
+ };
+ }
+ return null;
+ }
+
+ public dispose(): void {
+ // @ts-ignore
+ this.editor.removeContentWidget(this);
+ this.disposables.forEach(d => d.dispose());
+ }
+
+ protected updateContents(node: Node, extra?: Node): void {
+ this.domNode.textContent = '';
+ this.domNode.appendChild(node);
+ this.extraNode.innerHTML = '';
+ if (extra) {
+ this.extraNode.appendChild(extra);
+ }
+ this.updateFont();
+ // @ts-ignore
+ this.editor.layoutContentWidget(this);
+ this.onContentsChange();
+ }
+
+ protected onContentsChange(): void {
+ this.scrollbar.scanDomNode();
+ }
+
+ private updateMaxHeight() {
+ const height = Math.max(this.editor.getLayoutInfo().height / 4, 250);
+ const { fontSize, lineHeight } = this.editor.getConfiguration().fontInfo;
+
+ this.domNode.style.fontSize = `${fontSize}px`;
+ this.domNode.style.lineHeight = `${lineHeight}px`;
+ this.domNode.style.maxHeight = `${height}px`;
+ }
+
+ private updateFont(): void {
+ const codeTags: HTMLElement[] = Array.prototype.slice.call(
+ this.domNode.getElementsByTagName('code')
+ );
+ const codeClasses: HTMLElement[] = Array.prototype.slice.call(
+ this.domNode.getElementsByClassName('code')
+ );
+
+ [...codeTags, ...codeClasses].forEach(node => this.editor.applyFontInfo(node));
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/definition/definition_provider.ts b/x-pack/plugins/code/public/monaco/definition/definition_provider.ts
new file mode 100644
index 0000000000000..d0787904ebb8a
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/definition/definition_provider.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DetailSymbolInformation, SymbolLocator } from '@elastic/lsp-extension';
+import { flatten } from 'lodash';
+import { editor, languages } from 'monaco-editor';
+import { kfetch } from 'ui/kfetch';
+import { Location } from 'vscode-languageserver-types';
+import { LspRestClient, TextDocumentMethods } from '../../../common/lsp_client';
+
+export function provideDefinition(monaco: any, model: editor.ITextModel, position: any) {
+ const lspClient = new LspRestClient('/api/code/lsp');
+ const lspMethods = new TextDocumentMethods(lspClient);
+ function handleLocation(location: Location): languages.Location {
+ return {
+ uri: monaco.Uri.parse(location.uri),
+ range: {
+ startLineNumber: location.range.start.line + 1,
+ startColumn: location.range.start.character + 1,
+ endLineNumber: location.range.end.line + 1,
+ endColumn: location.range.end.character + 1,
+ },
+ };
+ }
+
+ async function handleQname(qname: string) {
+ const res: any = await kfetch({ pathname: `/api/code/lsp/symbol/${qname}` });
+ if (res.symbols) {
+ return res.symbols.map((s: DetailSymbolInformation) =>
+ handleLocation(s.symbolInformation.location)
+ );
+ }
+ return [];
+ }
+
+ return lspMethods.edefinition
+ .send({
+ position: {
+ line: position.lineNumber - 1,
+ character: position.column - 1,
+ },
+ textDocument: {
+ uri: model.uri.toString(),
+ },
+ })
+ .then(
+ (result: SymbolLocator[]) => {
+ if (result) {
+ const locations = result.filter(l => l.location !== undefined);
+ if (locations.length > 0) {
+ return locations.map(l => handleLocation(l.location!));
+ } else {
+ return Promise.all(
+ result.filter(l => l.qname !== undefined).map(l => handleQname(l.qname!))
+ ).then(flatten);
+ }
+ } else {
+ return [];
+ }
+ },
+ (_: any) => {
+ return [];
+ }
+ );
+}
diff --git a/x-pack/plugins/code/public/monaco/disposable.ts b/x-pack/plugins/code/public/monaco/disposable.ts
new file mode 100644
index 0000000000000..d89048357234e
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/disposable.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IDisposable } from 'monaco-editor';
+
+export abstract class Disposable implements IDisposable {
+ protected disposables: IDisposable[];
+
+ constructor() {
+ this.disposables = [];
+ }
+
+ public dispose(): void {
+ this.disposables.forEach(d => d.dispose());
+ this.disposables = [];
+ }
+
+ protected _register(t: T): T {
+ this.disposables.push(t);
+ return t;
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/editor_service.ts b/x-pack/plugins/code/public/monaco/editor_service.ts
new file mode 100644
index 0000000000000..e38fb1f93587c
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/editor_service.ts
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor, IRange, Uri } from 'monaco-editor';
+// @ts-ignore
+import { StandaloneCodeEditorServiceImpl } from 'monaco-editor/esm/vs/editor/standalone/browser/standaloneCodeServiceImpl.js';
+import { kfetch } from 'ui/kfetch';
+import { ResponseError } from 'vscode-jsonrpc/lib/messages';
+import { parseSchema } from '../../common/uri_util';
+import { SymbolSearchResult } from '../../model';
+import { history } from '../utils/url';
+import { MonacoHelper } from './monaco_helper';
+interface IResourceInput {
+ resource: Uri;
+ options?: { selection?: IRange };
+}
+
+export class EditorService extends StandaloneCodeEditorServiceImpl {
+ public static async handleSymbolUri(qname: string) {
+ const result = await EditorService.findSymbolByQname(qname);
+ if (result.symbols.length > 0) {
+ const symbol = result.symbols[0].symbolInformation;
+ const { schema, uri } = parseSchema(symbol.location.uri);
+ if (schema === 'git:') {
+ const { line, character } = symbol.location.range.start;
+ const url = uri + `!L${line + 1}:${character + 1}`;
+ history.push(url);
+ }
+ }
+ }
+
+ public static async findSymbolByQname(qname: string) {
+ try {
+ const response = await kfetch({
+ pathname: `/api/code/lsp/symbol/${qname}`,
+ method: 'GET',
+ });
+ return response as SymbolSearchResult;
+ } catch (e) {
+ const error = e.body;
+ throw new ResponseError(error.code, error.message, error.data);
+ }
+ }
+ private helper?: MonacoHelper;
+ public async openCodeEditor(
+ input: IResourceInput,
+ source: editor.ICodeEditor,
+ sideBySide?: boolean
+ ) {
+ const { scheme, authority, path } = input.resource;
+ if (scheme === 'symbol') {
+ await EditorService.handleSymbolUri(authority);
+ } else {
+ const uri = `/${authority}${path}`;
+ if (input.options && input.options.selection) {
+ const { startColumn, startLineNumber } = input.options.selection;
+ const url = uri + `!L${startLineNumber}:${startColumn}`;
+ const currentPath = window.location.hash.substring(1);
+ if (currentPath === url) {
+ this.helper!.revealPosition(startLineNumber, startColumn);
+ } else {
+ history.push(url);
+ }
+ }
+ }
+ return source;
+ }
+
+ public setMonacoHelper(helper: MonacoHelper) {
+ this.helper = helper;
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/hover/content_hover_widget.ts b/x-pack/plugins/code/public/monaco/hover/content_hover_widget.ts
new file mode 100644
index 0000000000000..ed5866d4cef6c
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/hover/content_hover_widget.ts
@@ -0,0 +1,208 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor as Editor, languages, Range as EditorRange } from 'monaco-editor';
+// @ts-ignore
+import { createCancelablePromise } from 'monaco-editor/esm/vs/base/common/async';
+// @ts-ignore
+import { getOccurrencesAtPosition } from 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/wordHighlighter';
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Hover, MarkedString, Range } from 'vscode-languageserver-types';
+import { ServerNotInitialized } from '../../../common/lsp_error_codes';
+import { HoverButtons } from '../../components/hover/hover_buttons';
+import { HoverState, HoverWidget, HoverWidgetProps } from '../../components/hover/hover_widget';
+import { ContentWidget } from '../content_widget';
+import { monaco } from '../monaco';
+import { Operation } from '../operation';
+import { HoverComputer } from './hover_computer';
+
+export class ContentHoverWidget extends ContentWidget {
+ public static ID = 'editor.contrib.contentHoverWidget';
+ private static readonly DECORATION_OPTIONS = {
+ className: 'wordHighlightStrong', // hoverHighlight wordHighlightStrong
+ };
+ private hoverOperation: Operation;
+ private readonly computer: HoverComputer;
+ private lastRange: EditorRange | null = null;
+ private shouldFocus: boolean = false;
+ private hoverResultAction?: (hover: Hover) => void = undefined;
+ private highlightDecorations: string[] = [];
+ private hoverState: HoverState = HoverState.LOADING;
+
+ constructor(editor: Editor.ICodeEditor) {
+ super(ContentHoverWidget.ID, editor);
+ this.containerDomNode.className = 'monaco-editor-hover hidden';
+ this.containerDomNode.tabIndex = 0;
+ this.domNode.className = 'monaco-editor-hover-content';
+ this.computer = new HoverComputer();
+ this.hoverOperation = new Operation(
+ this.computer,
+ result => this.result(result),
+ error => {
+ // @ts-ignore
+ if (error.code === ServerNotInitialized) {
+ this.hoverState = HoverState.INITIALIZING;
+ this.render(this.lastRange!);
+ }
+ },
+ () => {
+ this.hoverState = HoverState.LOADING;
+ this.render(this.lastRange!);
+ }
+ );
+ }
+
+ public startShowingAt(range: any, focus: boolean) {
+ if (this.isVisible && this.lastRange && this.lastRange.containsRange(range)) {
+ return;
+ }
+ this.hoverOperation.cancel();
+ const url = this.editor.getModel().uri.toString();
+ if (this.isVisible) {
+ this.hide();
+ }
+ this.computer.setParams(url, range);
+ this.hoverOperation.start();
+ this.lastRange = range;
+ this.shouldFocus = focus;
+ }
+
+ public setHoverResultAction(hoverResultAction: (hover: Hover) => void) {
+ this.hoverResultAction = hoverResultAction;
+ }
+
+ public hide(): void {
+ super.hide();
+ this.highlightDecorations = this.editor.deltaDecorations(this.highlightDecorations, []);
+ }
+
+ private result(result: Hover) {
+ if (this.hoverResultAction) {
+ // pass the result to redux
+ this.hoverResultAction(result);
+ }
+ if (this.lastRange && result && result.contents) {
+ this.render(this.lastRange, result);
+ } else {
+ this.hide();
+ }
+ }
+
+ private render(renderRange: EditorRange, result?: Hover) {
+ const fragment = document.createDocumentFragment();
+ let props: HoverWidgetProps = {
+ state: this.hoverState,
+ gotoDefinition: this.gotoDefinition.bind(this),
+ findReferences: this.findReferences.bind(this),
+ };
+ let startColumn = renderRange.startColumn;
+ if (result) {
+ let contents: MarkedString[] = [];
+ if (Array.isArray(result.contents)) {
+ contents = result.contents;
+ } else {
+ contents = [result.contents as MarkedString];
+ }
+ contents = contents.filter(v => {
+ if (typeof v === 'string') {
+ return !!v;
+ } else {
+ return !!v.value;
+ }
+ });
+ if (contents.length === 0) {
+ this.hide();
+ return;
+ }
+ props = {
+ ...props,
+ state: HoverState.READY,
+ contents,
+ };
+ if (result.range) {
+ this.lastRange = this.toMonacoRange(result.range);
+ this.highlightOccurrences(this.lastRange);
+ }
+ startColumn = Math.min(
+ renderRange.startColumn,
+ result.range ? result.range.start.character + 1 : Number.MAX_VALUE
+ );
+ }
+
+ this.showAt(new monaco.Position(renderRange.startLineNumber, startColumn), this.shouldFocus);
+ const element = React.createElement(HoverWidget, props, null);
+ // @ts-ignore
+ ReactDOM.render(element, fragment);
+ const buttonFragment = document.createDocumentFragment();
+ const buttons = React.createElement(HoverButtons, props, null);
+ // @ts-ignore
+ ReactDOM.render(buttons, buttonFragment);
+ this.updateContents(fragment, buttonFragment);
+ }
+
+ private toMonacoRange(r: Range): EditorRange {
+ return new monaco.Range(
+ r.start.line + 1,
+ r.start.character + 1,
+ r.end.line + 1,
+ r.end.character + 1
+ );
+ }
+
+ private gotoDefinition() {
+ if (this.lastRange) {
+ this.editor.setPosition({
+ lineNumber: this.lastRange.startLineNumber,
+ column: this.lastRange.startColumn,
+ });
+ const action = this.editor.getAction('editor.action.goToDeclaration');
+ action.run().then(() => this.hide());
+ }
+ }
+
+ private findReferences() {
+ if (this.lastRange) {
+ this.editor.setPosition({
+ lineNumber: this.lastRange.startLineNumber,
+ column: this.lastRange.startColumn,
+ });
+ const action = this.editor.getAction('editor.action.referenceSearch.trigger');
+ action.run().then(() => this.hide());
+ }
+ }
+
+ private highlightOccurrences(range: EditorRange) {
+ const pos = new monaco.Position(range.startLineNumber, range.startColumn);
+ return createCancelablePromise((token: any) =>
+ getOccurrencesAtPosition(this.editor.getModel(), pos, token).then(
+ (data: languages.DocumentHighlight[]) => {
+ if (data) {
+ if (this.isVisible) {
+ const decorations = data.map(h => ({
+ range: h.range,
+ options: ContentHoverWidget.DECORATION_OPTIONS,
+ }));
+
+ this.highlightDecorations = this.editor.deltaDecorations(
+ this.highlightDecorations,
+ decorations
+ );
+ }
+ } else {
+ this.highlightDecorations = this.editor.deltaDecorations(this.highlightDecorations, [
+ {
+ range,
+ options: ContentHoverWidget.DECORATION_OPTIONS,
+ },
+ ]);
+ }
+ }
+ )
+ );
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/hover/hover_computer.ts b/x-pack/plugins/code/public/monaco/hover/hover_computer.ts
new file mode 100644
index 0000000000000..3dbea3712f30e
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/hover/hover_computer.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Hover } from 'vscode-languageserver-types';
+import { LspRestClient, TextDocumentMethods } from '../../../common/lsp_client';
+import { AsyncTask, Computer } from '../computer';
+
+export const LOADING = 'loading';
+
+export class HoverComputer implements Computer {
+ private lspMethods: TextDocumentMethods;
+ private range: any = null;
+ private uri: string | null = null;
+
+ constructor() {
+ const lspClient = new LspRestClient('/api/code/lsp');
+ this.lspMethods = new TextDocumentMethods(lspClient);
+ }
+
+ public setParams(uri: string, range: any) {
+ this.range = range;
+ this.uri = uri;
+ }
+
+ public compute(): AsyncTask {
+ return this.lspMethods.hover.asyncTask({
+ position: {
+ line: this.range!.startLineNumber - 1,
+ character: this.range!.startColumn - 1,
+ },
+ textDocument: {
+ uri: this.uri!,
+ },
+ });
+ }
+
+ public loadingMessage(): Hover {
+ return {
+ range: {
+ start: {
+ line: this.range.startLineNumber - 1,
+ character: this.range.startColumn - 1,
+ },
+ end: {
+ line: this.range.endLineNumber - 1,
+ character: this.range.endColumn - 1,
+ },
+ },
+ contents: LOADING,
+ };
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/hover/hover_controller.ts b/x-pack/plugins/code/public/monaco/hover/hover_controller.ts
new file mode 100644
index 0000000000000..40ec6c9c171f7
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/hover/hover_controller.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor as Editor, IDisposable, IKeyboardEvent } from 'monaco-editor';
+import { EditorActions } from '../../components/editor/editor';
+import { monaco } from '../monaco';
+import { ContentHoverWidget } from './content_hover_widget';
+
+export class HoverController implements Editor.IEditorContribution {
+ public static ID = 'code.editor.contrib.hover';
+ public static get(editor: any): HoverController {
+ return editor.getContribution(HoverController.ID);
+ }
+ private contentWidget: ContentHoverWidget;
+ private disposables: IDisposable[];
+
+ constructor(readonly editor: Editor.ICodeEditor) {
+ this.disposables = [
+ this.editor.onMouseMove((e: Editor.IEditorMouseEvent) => this.onEditorMouseMove(e)),
+ this.editor.onKeyDown((e: IKeyboardEvent) => this.onKeyDown(e)),
+ ];
+ this.contentWidget = new ContentHoverWidget(editor);
+ }
+
+ public dispose(): void {
+ this.disposables.forEach(d => d.dispose());
+ }
+
+ public getId(): string {
+ return HoverController.ID;
+ }
+
+ public setReduxActions(actions: EditorActions) {
+ this.contentWidget.setHoverResultAction(actions.hoverResult);
+ }
+
+ private onEditorMouseMove(mouseEvent: Editor.IEditorMouseEvent) {
+ const targetType = mouseEvent.target.type;
+ const { MouseTargetType } = monaco.editor;
+
+ if (
+ targetType === MouseTargetType.CONTENT_WIDGET &&
+ mouseEvent.target.detail === ContentHoverWidget.ID
+ ) {
+ return;
+ }
+
+ if (targetType === MouseTargetType.CONTENT_TEXT) {
+ this.contentWidget.startShowingAt(mouseEvent.target.range, false);
+ } else {
+ this.contentWidget.hide();
+ }
+ }
+
+ private onKeyDown(e: IKeyboardEvent): void {
+ if (e.keyCode === monaco.KeyCode.Escape) {
+ // Do not hide hover when Ctrl/Meta is pressed
+ this.contentWidget.hide();
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/immortal_reference.ts b/x-pack/plugins/code/public/monaco/immortal_reference.ts
new file mode 100644
index 0000000000000..b1ec674c1ee9c
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/immortal_reference.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IReference } from './textmodel_resolver';
+
+export class ImmortalReference implements IReference {
+ constructor(public object: T) {}
+ public dispose(): void {
+ /* noop */
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/monaco.ts b/x-pack/plugins/code/public/monaco/monaco.ts
new file mode 100644
index 0000000000000..9afb09f279a78
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/monaco.ts
@@ -0,0 +1,156 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+// (1) Desired editor features:
+import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js';
+import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js';
+import 'monaco-editor/esm/vs/editor/browser/widget/diffEditorWidget.js';
+// import 'monaco-editor/esm/vs/editor/browser/widget/diffNavigator.js';
+// import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/bracketMatching.js';
+// import 'monaco-editor/esm/vs/editor/contrib/caretOperations/caretOperations.js';
+// import 'monaco-editor/esm/vs/editor/contrib/caretOperations/transpose.js';
+import 'monaco-editor/esm/vs/editor/contrib/clipboard/clipboard.js';
+// import 'monaco-editor/esm/vs/editor/contrib/codelens/codelensController.js';
+// import 'monaco-editor/esm/vs/editor/contrib/colorPicker/colorDetector.js';
+// import 'monaco-editor/esm/vs/editor/contrib/comment/comment.js';
+// import 'monaco-editor/esm/vs/editor/contrib/contextmenu/contextmenu.js';
+// import 'monaco-editor/esm/vs/editor/contrib/cursorUndo/cursorUndo.js';
+// import 'monaco-editor/esm/vs/editor/contrib/dnd/dnd.js';
+import 'monaco-editor/esm/vs/editor/contrib/find/findController.js';
+import 'monaco-editor/esm/vs/editor/contrib/folding/folding.js';
+// import 'monaco-editor/esm/vs/editor/contrib/format/formatActions.js';
+import 'monaco-editor/esm/vs/editor/contrib/goToDefinition/goToDefinitionCommands';
+import 'monaco-editor/esm/vs/editor/contrib/goToDefinition/goToDefinitionMouse';
+// import 'monaco-editor/esm/vs/editor/contrib/gotoError/gotoError.js';
+// import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js';
+// import 'monaco-editor/esm/vs/editor/contrib/inPlaceReplace/inPlaceReplace.js';
+// import 'monaco-editor/esm/vs/editor/contrib/linesOperations/linesOperations.js';
+// import 'monaco-editor/esm/vs/editor/contrib/links/links.js';
+// import 'monaco-editor/esm/vs/editor/contrib/multicursor/multicursor.js';
+// import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js';
+// import 'monaco-editor/esm/vs/editor/contrib/quickFix/quickFixCommands.js';
+// import 'monaco-editor/esm/vs/editor/contrib/referenceSearch/referenceSearch.js';
+// import 'monaco-editor/esm/vs/editor/contrib/rename/rename.js';
+// import 'monaco-editor/esm/vs/editor/contrib/smartSelect/smartSelect.js';
+// import 'monaco-editor/esm/vs/editor/contrib/snippet/snippetController2.js';
+// import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js';
+// import 'monaco-editor/esm/vs/editor/contrib/toggleTabFocusMode/toggleTabFocusMode.js';
+// import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/wordHighlighter.js';
+// import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js';
+// import 'monaco-editor/esm/vs/editor/standalone/browser/accessibilityHelp/accessibilityHelp.js';
+// import 'monaco-editor/esm/vs/editor/standalone/browser/inspectTokens/inspectTokens.js';
+// import 'monaco-editor/esm/vs/editor/standalone/browser/iPadShowKeyboard/iPadShowKeyboard.js';
+// import 'monaco-editor/esm/vs/editor/standalone/browser/quickOpen/quickOutline.js';
+// import 'monaco-editor/esm/vs/editor/standalone/browser/quickOpen/gotoLine.js';
+// import 'monaco-editor/esm/vs/editor/standalone/browser/quickOpen/quickCommand.js';
+// import 'monaco-editor/esm/vs/editor/standalone/browser/toggleHighContrast/toggleHighContrast.js';
+import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
+
+import darkTheme from '@elastic/eui/dist/eui_theme_dark.json';
+import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
+// (2) Desired languages:
+// import 'monaco-editor/esm/vs/language/typescript/monaco.contribution';
+// import 'monaco-editor/esm/vs/language/css/monaco.contribution';
+// import 'monaco-editor/esm/vs/language/json/monaco.contribution';
+// import 'monaco-editor/esm/vs/language/html/monaco.contribution';
+// import 'monaco-editor/esm/vs/basic-languages/bat/bat.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/coffee/coffee.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/csharp/csharp.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/csp/csp.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/css/css.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/dockerfile/dockerfile.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/fsharp/fsharp.contribution.js';
+import 'monaco-editor/esm/vs/basic-languages/go/go.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/handlebars/handlebars.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/html/html.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/ini/ini.contribution.js';
+import 'monaco-editor/esm/vs/basic-languages/java/java.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/r/r.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/razor/razor.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/redis/redis.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/redshift/redshift.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/ruby/ruby.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/sb/sb.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/scss/scss.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/solidity/solidity.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/swift/swift.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/vb/vb.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js';
+import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution';
+// import 'monaco-editor/esm/vs/basic-languages/less/less.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/lua/lua.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/msdax/msdax.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/mysql/mysql.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/objective-c/objective-c.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/pgsql/pgsql.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/php/php.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/postiats/postiats.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/powershell/powershell.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/pug/pug.contribution.js';
+// import 'monaco-editor/esm/vs/basic-languages/python/python.contribution.js';
+import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution';
+import chrome from 'ui/chrome';
+
+const IS_DARK_THEME = chrome.getUiSettingsClient().get('theme:darkMode');
+
+const themeName = IS_DARK_THEME ? darkTheme : lightTheme;
+
+const syntaxTheme = {
+ keyword: themeName.euiColorAccent,
+ comment: themeName.euiColorMediumShade,
+ delimiter: themeName.euiColorSecondary,
+ string: themeName.euiColorPrimary,
+ number: themeName.euiColorWarning,
+ regexp: themeName.euiColorPrimary,
+ types: `${IS_DARK_THEME ? themeName.euiColorVis5 : themeName.euiColorVis9}`,
+ annotation: themeName.euiColorLightShade,
+ tag: themeName.euiColorAccent,
+ symbol: themeName.euiColorDanger,
+ foreground: themeName.euiColorDarkestShade,
+ editorBackground: themeName.euiColorLightestShade,
+ lineNumbers: themeName.euiColorDarkShade,
+ editorIndentGuide: themeName.euiColorLightShade,
+ selectionBackground: themeName.euiColorLightShade,
+ editorWidgetBackground: themeName.euiColorLightestShade,
+ editorWidgetBorder: themeName.euiColorLightShade,
+ findMatchBackground: themeName.euiColorWarning,
+ findMatchHighlightBackground: themeName.euiColorWarning,
+};
+
+monaco.editor.defineTheme('euiColors', {
+ base: 'vs',
+ inherit: true,
+ rules: [
+ { token: 'keyword', foreground: syntaxTheme.keyword, fontStyle: 'bold' },
+ { token: 'comment', foreground: syntaxTheme.comment },
+ { token: 'delimiter', foreground: syntaxTheme.delimiter },
+ { token: 'string', foreground: syntaxTheme.string },
+ { token: 'number', foreground: syntaxTheme.number },
+ { token: 'regexp', foreground: syntaxTheme.regexp },
+ { token: 'type', foreground: syntaxTheme.types },
+ { token: 'annotation', foreground: syntaxTheme.annotation },
+ { token: 'tag', foreground: syntaxTheme.tag },
+ { token: 'symbol', foreground: syntaxTheme.symbol },
+ // We provide an empty string fallback
+ { token: '', foreground: syntaxTheme.foreground },
+ ],
+ colors: {
+ 'editor.foreground': syntaxTheme.foreground,
+ 'editor.background': syntaxTheme.editorBackground,
+ 'editorLineNumber.foreground': syntaxTheme.lineNumbers,
+ 'editorLineNumber.activeForeground': syntaxTheme.lineNumbers,
+ 'editorIndentGuide.background': syntaxTheme.editorIndentGuide,
+ 'editor.selectionBackground': syntaxTheme.selectionBackground,
+ 'editorWidget.border': syntaxTheme.editorWidgetBorder,
+ 'editorWidget.background': syntaxTheme.editorWidgetBackground,
+ },
+});
+monaco.editor.setTheme('euiColors');
+
+export { monaco };
diff --git a/x-pack/plugins/code/public/monaco/monaco_diff_editor.ts b/x-pack/plugins/code/public/monaco/monaco_diff_editor.ts
new file mode 100644
index 0000000000000..98486da772d84
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/monaco_diff_editor.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { ResizeChecker } from 'ui/resize_checker';
+import { monaco } from './monaco';
+export class MonacoDiffEditor {
+ public diffEditor: monaco.editor.IDiffEditor | null = null;
+ private resizeChecker: ResizeChecker | null = null;
+ constructor(
+ private readonly container: HTMLElement,
+ private readonly originCode: string,
+ private readonly modifiedCode: string,
+ private readonly language: string,
+ private readonly renderSideBySide: boolean
+ ) {}
+
+ public init() {
+ return new Promise(resolve => {
+ const originalModel = monaco.editor.createModel(this.originCode, this.language);
+ const modifiedModel = monaco.editor.createModel(this.modifiedCode, this.language);
+
+ const diffEditor = monaco.editor.createDiffEditor(this.container, {
+ enableSplitViewResizing: false,
+ renderSideBySide: this.renderSideBySide,
+ scrollBeyondLastLine: false,
+ });
+ this.resizeChecker = new ResizeChecker(this.container);
+ this.resizeChecker.on('resize', () => {
+ setTimeout(() => {
+ this.diffEditor!.layout();
+ });
+ });
+ diffEditor.setModel({
+ original: originalModel,
+ modified: modifiedModel,
+ });
+ this.diffEditor = diffEditor;
+ });
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/monaco_helper.ts b/x-pack/plugins/code/public/monaco/monaco_helper.ts
new file mode 100644
index 0000000000000..f1b6e3871cd58
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/monaco_helper.ts
@@ -0,0 +1,166 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor } from 'monaco-editor';
+import chrome from 'ui/chrome';
+import { ResizeChecker } from 'ui/resize_checker';
+import { EditorActions } from '../components/editor/editor';
+import { provideDefinition } from './definition/definition_provider';
+
+import { toCanonicalUrl } from '../../common/uri_util';
+import { EditorService } from './editor_service';
+import { HoverController } from './hover/hover_controller';
+import { monaco } from './monaco';
+import { registerReferencesAction } from './references/references_action';
+import { registerEditor } from './single_selection_helper';
+import { TextModelResolverService } from './textmodel_resolver';
+
+export class MonacoHelper {
+ public get initialized() {
+ return this.monaco !== null;
+ }
+ public decorations: string[] = [];
+ public editor: editor.IStandaloneCodeEditor | null = null;
+ private monaco: any | null = null;
+ private resizeChecker: ResizeChecker | null = null;
+
+ constructor(
+ public readonly container: HTMLElement,
+ private readonly editorActions: EditorActions
+ ) {
+ this.handleCopy = this.handleCopy.bind(this);
+ }
+ public init() {
+ return new Promise(resolve => {
+ this.monaco = monaco;
+ const definitionProvider = {
+ provideDefinition(model: any, position: any) {
+ return provideDefinition(monaco, model, position);
+ },
+ };
+ this.monaco.languages.registerDefinitionProvider('java', definitionProvider);
+ this.monaco.languages.registerDefinitionProvider('typescript', definitionProvider);
+ this.monaco.languages.registerDefinitionProvider('javascript', definitionProvider);
+ if (chrome.getInjected('enableLangserversDeveloping', false) === true) {
+ this.monaco.languages.registerDefinitionProvider('go', definitionProvider);
+ }
+ const codeEditorService = new EditorService();
+ codeEditorService.setMonacoHelper(this);
+ this.editor = monaco.editor.create(
+ this.container!,
+ {
+ readOnly: true,
+ minimap: {
+ enabled: false,
+ },
+ hover: {
+ enabled: false, // disable default hover;
+ },
+ occurrencesHighlight: false,
+ selectionHighlight: false,
+ renderLineHighlight: 'none',
+ contextmenu: false,
+ folding: true,
+ scrollBeyondLastLine: false,
+ renderIndentGuides: false,
+ automaticLayout: false,
+ },
+ {
+ textModelService: new TextModelResolverService(monaco),
+ codeEditorService,
+ }
+ );
+ registerEditor(this.editor);
+ this.resizeChecker = new ResizeChecker(this.container);
+ this.resizeChecker.on('resize', () => {
+ setTimeout(() => {
+ this.editor!.layout();
+ });
+ });
+ registerReferencesAction(this.editor);
+ const hoverController: HoverController = new HoverController(this.editor);
+ hoverController.setReduxActions(this.editorActions);
+ document.addEventListener('copy', this.handleCopy);
+ document.addEventListener('cut', this.handleCopy);
+ resolve(this.editor);
+ });
+ }
+
+ public destroy = () => {
+ this.monaco = null;
+ document.removeEventListener('copy', this.handleCopy);
+ document.removeEventListener('cut', this.handleCopy);
+
+ if (this.resizeChecker) {
+ this.resizeChecker!.destroy();
+ }
+ };
+
+ public async loadFile(
+ repoUri: string,
+ file: string,
+ text: string,
+ lang: string,
+ revision: string = 'master'
+ ) {
+ if (!this.initialized) {
+ await this.init();
+ }
+ const ed = this.editor!;
+ const oldModel = ed.getModel();
+ if (oldModel) {
+ oldModel.dispose();
+ }
+ ed.setModel(null);
+ const uri = this.monaco!.Uri.parse(
+ toCanonicalUrl({ schema: 'git:', repoUri, file, revision, pathType: 'blob' })
+ );
+ let newModel = this.monaco!.editor.getModel(uri);
+ if (!newModel) {
+ newModel = this.monaco!.editor.createModel(text, lang, uri);
+ } else {
+ newModel.setValue(text);
+ }
+ ed.setModel(newModel);
+ return ed;
+ }
+
+ public revealPosition(line: number, pos: number) {
+ const position = {
+ lineNumber: line,
+ column: pos,
+ };
+ this.decorations = this.editor!.deltaDecorations(this.decorations, [
+ {
+ range: new this.monaco!.Range(line, 0, line, 0),
+ options: {
+ isWholeLine: true,
+ className: `code-monaco-highlight-line code-line-number-${line}`,
+ linesDecorationsClassName: 'code-mark-line-number',
+ },
+ },
+ ]);
+ this.editor!.setPosition(position);
+ this.editor!.revealLineInCenterIfOutsideViewport(line);
+ }
+
+ public clearLineSelection() {
+ this.decorations = this.editor!.deltaDecorations(this.decorations, []);
+ }
+
+ private handleCopy(e: any) {
+ if (
+ this.editor &&
+ this.editor.hasTextFocus() &&
+ this.editor.hasWidgetFocus() &&
+ !this.editor.getSelection().isEmpty()
+ ) {
+ const text = this.editor.getModel().getValueInRange(this.editor.getSelection());
+ e.clipboardData.setData('text/plain', text);
+ e.preventDefault();
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/operation.ts b/x-pack/plugins/code/public/monaco/operation.ts
new file mode 100644
index 0000000000000..0718166d65e3b
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/operation.ts
@@ -0,0 +1,90 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { AsyncTask, Computer } from './computer';
+
+enum OperationState {
+ IDLE,
+ DELAYED,
+ RUNNING,
+}
+
+export class Operation {
+ public static DEFAULT_DELAY_TIME = 300;
+ private task: AsyncTask | null = null;
+ private state: OperationState = OperationState.IDLE;
+ private delay: number = Operation.DEFAULT_DELAY_TIME;
+ private timeout: any;
+
+ constructor(
+ readonly computer: Computer,
+ readonly successCallback: (result: T) => void,
+ readonly errorCallback: (error: Error) => void,
+ readonly progressCallback: (progress: any) => void
+ ) {}
+
+ public setDelay(delay: number) {
+ this.delay = delay;
+ }
+
+ public start() {
+ if (this.state === OperationState.IDLE) {
+ this.task = this.computer.compute();
+ this.triggerDelay();
+ }
+ }
+
+ public triggerDelay() {
+ this.cancelDelay();
+ this.timeout = setTimeout(this.triggerAsyncTask.bind(this), this.delay);
+ this.state = OperationState.DELAYED;
+ }
+
+ public cancel() {
+ if (this.state === OperationState.RUNNING) {
+ if (this.task) {
+ this.task.cancel();
+ this.task = null;
+ }
+ } else if (this.state === OperationState.DELAYED) {
+ this.cancelDelay();
+ }
+ this.state = OperationState.IDLE;
+ }
+
+ private cancelDelay() {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }
+ }
+
+ private showLoading() {
+ this.progressCallback(this.computer.loadingMessage());
+ }
+
+ private triggerAsyncTask() {
+ if (this.task) {
+ this.state = OperationState.RUNNING;
+ const loadingDelay = setTimeout(this.showLoading.bind(this), this.delay);
+
+ const task = this.task;
+ task.promise().then(
+ result => {
+ clearTimeout(loadingDelay);
+ if (task === this.task) {
+ this.successCallback(result);
+ }
+ },
+ error => {
+ clearTimeout(loadingDelay);
+ if (task === this.task) {
+ this.errorCallback(error);
+ }
+ }
+ );
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/monaco/override_monaco_styles.scss b/x-pack/plugins/code/public/monaco/override_monaco_styles.scss
new file mode 100644
index 0000000000000..b5990ac7a8270
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/override_monaco_styles.scss
@@ -0,0 +1,12 @@
+.monaco-editor .cursors-layer > .cursor {
+ display: none !important;
+}
+
+textarea.inputarea {
+ display: none !important;
+}
+
+.monaco-editor.mac .margin-view-overlays .line-numbers {
+ cursor: pointer;
+ background-color: $euiColorLightestShade;
+}
diff --git a/x-pack/plugins/code/public/monaco/references/references_action.ts b/x-pack/plugins/code/public/monaco/references/references_action.ts
new file mode 100644
index 0000000000000..58926c8008a59
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/references/references_action.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { editor } from 'monaco-editor';
+import queryString from 'querystring';
+import { parseSchema } from '../../../common/uri_util';
+import { history } from '../../utils/url';
+
+export function registerReferencesAction(e: editor.IStandaloneCodeEditor) {
+ e.addAction({
+ id: 'editor.action.referenceSearch.trigger',
+ label: 'Find All References',
+ contextMenuGroupId: 'navigation',
+ contextMenuOrder: 1.5,
+ run(ed: editor.ICodeEditor) {
+ const position = ed.getPosition();
+ const { uri } = parseSchema(ed.getModel().uri.toString());
+ const refUrl = `git:/${uri}!L${position.lineNumber - 1}:${position.column - 1}`;
+ const queries = queryString.parse(location.search);
+ const query = queryString.stringify({
+ ...queries,
+ tab: 'references',
+ refUrl,
+ });
+ history.push(`${uri}?${query}`);
+ },
+ });
+}
diff --git a/x-pack/plugins/code/public/monaco/single_selection_helper.ts b/x-pack/plugins/code/public/monaco/single_selection_helper.ts
new file mode 100644
index 0000000000000..f63296aeab55a
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/single_selection_helper.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor, Selection } from 'monaco-editor';
+
+const editors = new Set();
+
+function clearSelection(ed: editor.IStandaloneCodeEditor) {
+ const sel: Selection = ed.getSelection();
+ if (sel && !sel.isEmpty()) {
+ ed.setSelection({
+ selectionStartLineNumber: sel.selectionStartLineNumber,
+ selectionStartColumn: sel.selectionStartColumn,
+ positionLineNumber: sel.selectionStartLineNumber,
+ positionColumn: sel.selectionStartColumn,
+ });
+ }
+}
+
+export function registerEditor(ed: editor.IStandaloneCodeEditor) {
+ editors.add(ed);
+ ed.onDidChangeCursorSelection(() => {
+ editors.forEach(e => {
+ if (e !== ed) {
+ clearSelection(e);
+ }
+ });
+ });
+ ed.onDidDispose(() => editors.delete(ed));
+}
diff --git a/x-pack/plugins/code/public/monaco/textmodel_resolver.ts b/x-pack/plugins/code/public/monaco/textmodel_resolver.ts
new file mode 100644
index 0000000000000..1336bc03a4262
--- /dev/null
+++ b/x-pack/plugins/code/public/monaco/textmodel_resolver.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { editor, IDisposable, Uri } from 'monaco-editor';
+import chrome from 'ui/chrome';
+
+import { ImmortalReference } from './immortal_reference';
+
+export interface IReference extends IDisposable {
+ readonly object: T;
+}
+
+export interface ITextModelService {
+ /**
+ * Provided a resource URI, it will return a model reference
+ * which should be disposed once not needed anymore.
+ */
+ createModelReference(resource: Uri): Promise>;
+
+ /**
+ * Registers a specific `scheme` content provider.
+ */
+ registerTextModelContentProvider(scheme: string, provider: any): IDisposable;
+}
+
+export class TextModelResolverService implements ITextModelService {
+ constructor(private readonly monaco: any) {}
+
+ public async createModelReference(resource: Uri): Promise> {
+ let model = this.monaco.editor.getModel(resource);
+ if (!model) {
+ const result = await this.fetchText(resource);
+ if (!result) {
+ return new ImmortalReference(null);
+ } else {
+ model = this.monaco.editor.createModel(result.text, result.lang, resource);
+ }
+ }
+ return new ImmortalReference({ textEditorModel: model });
+ }
+
+ public registerTextModelContentProvider(scheme: string, provider: any): IDisposable {
+ return {
+ dispose() {
+ /* no op */
+ },
+ };
+ }
+
+ private async fetchText(resource: Uri) {
+ const repo = `${resource.authority}${resource.path}`;
+ const revision = resource.query;
+ const file = resource.fragment;
+ const response = await fetch(
+ chrome.addBasePath(`/api/code/repo/${repo}/blob/${revision}/${file}`)
+ );
+ if (response.status === 200) {
+ const contentType = response.headers.get('Content-Type');
+
+ if (contentType && contentType.startsWith('text/')) {
+ const lang = contentType.split(';')[0].substring('text/'.length);
+ const text = await response.text();
+ return { text, lang };
+ }
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/reducers/blame.ts b/x-pack/plugins/code/public/reducers/blame.ts
new file mode 100644
index 0000000000000..987a176a688de
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/blame.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import produce from 'immer';
+import { Action, handleActions } from 'redux-actions';
+
+import { GitBlame } from '../../common/git_blame';
+import { loadBlame, loadBlameFailed, loadBlameSuccess } from '../actions/blame';
+
+export interface BlameState {
+ blames: GitBlame[];
+ loading: boolean;
+ error?: Error;
+}
+
+const initialState: BlameState = {
+ blames: [],
+ loading: false,
+};
+
+export const blame = handleActions(
+ {
+ [String(loadBlame)]: (state: BlameState) =>
+ produce(state, draft => {
+ draft.loading = true;
+ }),
+ [String(loadBlameSuccess)]: (state: BlameState, action: Action) =>
+ produce(state, (draft: BlameState) => {
+ draft.blames = action.payload!;
+ draft.loading = false;
+ }),
+ [String(loadBlameFailed)]: (state: BlameState, action: Action) =>
+ produce(state, draft => {
+ draft.loading = false;
+ draft.error = action.payload;
+ draft.blames = [];
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/commit.ts b/x-pack/plugins/code/public/reducers/commit.ts
new file mode 100644
index 0000000000000..4b0ce0e2afb6a
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/commit.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import produce from 'immer';
+import { Action, handleActions } from 'redux-actions';
+
+import { AnyAction } from 'redux';
+import { CommitDiff } from '../../common/git_diff';
+import { loadCommit, loadCommitFailed, loadCommitSuccess } from '../actions/commit';
+
+export interface CommitState {
+ commit: CommitDiff | null;
+ loading: boolean;
+}
+
+const initialState: CommitState = {
+ commit: null,
+ loading: false,
+};
+
+export const commit = handleActions(
+ {
+ [String(loadCommit)]: (state: CommitState, action: Action) =>
+ produce(state, draft => {
+ draft.loading = true;
+ }),
+ [String(loadCommitSuccess)]: (state: CommitState, action: Action) =>
+ produce(state, draft => {
+ draft.commit = action.payload;
+ draft.loading = false;
+ }),
+ [String(loadCommitFailed)]: (state: CommitState, action: Action) =>
+ produce(state, draft => {
+ draft.commit = null;
+ draft.loading = false;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/editor.ts b/x-pack/plugins/code/public/reducers/editor.ts
new file mode 100644
index 0000000000000..174116e04607e
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/editor.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import produce from 'immer';
+import { handleActions } from 'redux-actions';
+import { Hover, TextDocumentPositionParams } from 'vscode-languageserver';
+import {
+ closeReferences,
+ findReferences,
+ findReferencesFailed,
+ findReferencesSuccess,
+ GroupedRepoReferences,
+ hoverResult,
+ revealPosition,
+} from '../actions';
+
+export interface EditorState {
+ loading: boolean;
+ showing: boolean;
+ references: GroupedRepoReferences[];
+ hover?: Hover;
+ currentHover?: Hover;
+ refPayload?: TextDocumentPositionParams;
+ revealPosition?: Position;
+ referencesTitle: string;
+}
+const initialState: EditorState = {
+ loading: false,
+ showing: false,
+ references: [],
+ referencesTitle: '',
+};
+
+export const editor = handleActions(
+ {
+ [String(findReferences)]: (state: EditorState, action: any) =>
+ produce(state, (draft: EditorState) => {
+ draft.refPayload = action.payload;
+ draft.showing = true;
+ draft.loading = true;
+ draft.references = initialState.references;
+ draft.hover = state.currentHover;
+ draft.referencesTitle = initialState.referencesTitle;
+ }),
+ [String(findReferencesSuccess)]: (state: EditorState, action: any) =>
+ produce(state, draft => {
+ const { title, repos } = action.payload;
+ draft.references = repos;
+ draft.referencesTitle = title;
+ draft.loading = false;
+ }),
+ [String(findReferencesFailed)]: (state: EditorState) =>
+ produce(state, draft => {
+ draft.references = [];
+ draft.loading = false;
+ draft.refPayload = undefined;
+ }),
+ [String(closeReferences)]: (state: EditorState) =>
+ produce(state, draft => {
+ draft.showing = false;
+ draft.loading = false;
+ draft.refPayload = undefined;
+ draft.references = [];
+ }),
+ [String(hoverResult)]: (state: EditorState, action: any) =>
+ produce(state, draft => {
+ draft.currentHover = action.payload;
+ }),
+ [String(revealPosition)]: (state: EditorState, action: any) =>
+ produce(state, draft => {
+ draft.revealPosition = action.payload;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/file.ts b/x-pack/plugins/code/public/reducers/file.ts
new file mode 100644
index 0000000000000..bee97cf2ff96b
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/file.ts
@@ -0,0 +1,233 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import produce from 'immer';
+import { Action, handleActions } from 'redux-actions';
+import { FileTree, FileTreeItemType, sortFileTree } from '../../model';
+import { CommitInfo, ReferenceInfo, ReferenceType } from '../../model/commit';
+import {
+ closeTreePath,
+ fetchDirectory,
+ fetchDirectorySuccess,
+ fetchFileFailed,
+ FetchFileResponse,
+ fetchFileSuccess,
+ fetchMoreCommits,
+ fetchRepoBranchesSuccess,
+ fetchRepoCommitsSuccess,
+ fetchRepoTree,
+ fetchRepoTreeFailed,
+ fetchRepoTreeSuccess,
+ fetchTreeCommits,
+ fetchTreeCommitsFailed,
+ fetchTreeCommitsSuccess,
+ openTreePath,
+ RepoTreePayload,
+ resetRepoTree,
+ routeChange,
+ setNotFound,
+} from '../actions';
+
+export interface FileState {
+ tree: FileTree;
+ loading: boolean;
+ openedPaths: string[];
+ branches: ReferenceInfo[];
+ tags: ReferenceInfo[];
+ commits: CommitInfo[];
+ file?: FetchFileResponse;
+ opendir?: FileTree;
+ isNotFound: boolean;
+ treeCommits: { [path: string]: CommitInfo[] };
+ currentPath: string;
+ loadingCommits: boolean;
+ commitsFullyLoaded: { [path: string]: boolean };
+}
+
+const initialState: FileState = {
+ tree: {
+ name: '',
+ path: '',
+ children: undefined,
+ type: FileTreeItemType.Directory,
+ },
+ openedPaths: [],
+ loading: true,
+ branches: [],
+ tags: [],
+ commits: [],
+ treeCommits: {},
+ isNotFound: false,
+ currentPath: '',
+ loadingCommits: false,
+ commitsFullyLoaded: {},
+};
+
+function mergeNode(a: FileTree, b: FileTree): FileTree {
+ const childrenMap: { [name: string]: FileTree } = {};
+ if (a.children) {
+ a.children.forEach(child => {
+ childrenMap[child.name] = child;
+ });
+ }
+ if (b.children) {
+ b.children.forEach(childB => {
+ const childA = childrenMap[childB.name];
+ if (childA) {
+ childrenMap[childB.name] = mergeNode(childA, childB);
+ } else {
+ childrenMap[childB.name] = childB;
+ }
+ });
+ }
+ return {
+ ...a,
+ ...b,
+ children: Object.values(childrenMap).sort(sortFileTree),
+ };
+}
+
+export function getPathOfTree(tree: FileTree, paths: string[]) {
+ let child: FileTree | undefined = tree;
+ for (const p of paths) {
+ if (child && child.children) {
+ child = child.children.find(c => c.name === p);
+ } else {
+ return null;
+ }
+ }
+ return child;
+}
+
+export const file = handleActions(
+ {
+ [String(fetchRepoTree)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.currentPath = action.payload.path;
+ }),
+ [String(fetchRepoTreeSuccess)]: (state: FileState, action: Action) =>
+ produce(state, (draft: FileState) => {
+ draft.loading = false;
+ const { tree, path, withParents } = action.payload!;
+ if (withParents || path === '/' || path === '') {
+ draft.tree = mergeNode(draft.tree, tree);
+ } else {
+ const parentsPath = path.split('/');
+ const lastPath = parentsPath.pop();
+ const parent = getPathOfTree(draft.tree, parentsPath);
+ if (parent) {
+ parent.children = parent.children || [];
+ const idx = parent.children.findIndex(c => c.name === lastPath);
+ if (idx >= 0) {
+ parent.children[idx] = tree;
+ } else {
+ parent.children.push(tree);
+ }
+ }
+ }
+ }),
+ [String(resetRepoTree)]: (state: FileState) =>
+ produce(state, (draft: FileState) => {
+ draft.tree = initialState.tree;
+ draft.openedPaths = initialState.openedPaths;
+ }),
+ [String(fetchRepoTreeFailed)]: (state: FileState) =>
+ produce(state, draft => {
+ draft.loading = false;
+ }),
+ [String(openTreePath)]: (state: FileState, action: Action) =>
+ produce(state, (draft: FileState) => {
+ let path = action.payload!;
+ const openedPaths = state.openedPaths;
+ const pathSegs = path.split('/');
+ while (!openedPaths.includes(path)) {
+ draft.openedPaths.push(path);
+ pathSegs.pop();
+ if (pathSegs.length <= 0) {
+ break;
+ }
+ path = pathSegs.join('/');
+ }
+ }),
+ [String(closeTreePath)]: (state: FileState, action: Action) =>
+ produce(state, (draft: FileState) => {
+ const path = action.payload!;
+ const isSubFolder = (p: string) => p.startsWith(path + '/');
+ draft.openedPaths = state.openedPaths.filter(p => !(p === path || isSubFolder(p)));
+ }),
+ [String(fetchRepoCommitsSuccess)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.commits = action.payload;
+ draft.loadingCommits = false;
+ }),
+ [String(fetchMoreCommits)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.loadingCommits = true;
+ }),
+ [String(fetchRepoBranchesSuccess)]: (state: FileState, action: any) =>
+ produce(state, (draft: FileState) => {
+ const references = action.payload as ReferenceInfo[];
+ draft.tags = references.filter(r => r.type === ReferenceType.TAG);
+ draft.branches = references.filter(r => r.type !== ReferenceType.TAG);
+ }),
+ [String(fetchFileSuccess)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.file = action.payload as FetchFileResponse;
+ draft.isNotFound = false;
+ }),
+ [String(fetchFileFailed)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.file = undefined;
+ }),
+ [String(fetchDirectorySuccess)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.opendir = action.payload;
+ }),
+ [String(fetchDirectory)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.opendir = undefined;
+ }),
+ [String(setNotFound)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.isNotFound = action.payload;
+ }),
+ [String(routeChange)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ draft.isNotFound = false;
+ }),
+ [String(fetchTreeCommits)]: (state: FileState) =>
+ produce(state, draft => {
+ draft.loadingCommits = true;
+ }),
+ [String(fetchTreeCommitsFailed)]: (state: FileState) =>
+ produce(state, draft => {
+ draft.loadingCommits = false;
+ }),
+ [String(fetchTreeCommitsSuccess)]: (state: FileState, action: any) =>
+ produce(state, draft => {
+ const { path, commits, append } = action.payload;
+ if (path === '' || path === '/') {
+ if (commits.length === 0) {
+ draft.commitsFullyLoaded[''] = true;
+ } else if (append) {
+ draft.commits = draft.commits.concat(commits);
+ } else {
+ draft.commits = commits;
+ }
+ } else {
+ if (commits.length === 0) {
+ draft.commitsFullyLoaded[path] = true;
+ } else if (append) {
+ draft.treeCommits[path] = draft.treeCommits[path].concat(commits);
+ } else {
+ draft.treeCommits[path] = commits;
+ }
+ }
+ draft.loadingCommits = false;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/index.ts b/x-pack/plugins/code/public/reducers/index.ts
new file mode 100644
index 0000000000000..8c8ebbee447ae
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/index.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { combineReducers } from 'redux';
+
+import { blame, BlameState } from './blame';
+import { commit, CommitState } from './commit';
+import { editor, EditorState } from './editor';
+import { file, FileState } from './file';
+import { languageServer, LanguageServerState } from './language_server';
+import { repository, RepositoryState } from './repository';
+import { route, RouteState } from './route';
+import { search, SearchState } from './search';
+import { setup, SetupState } from './setup';
+import { shortcuts, ShortcutsState } from './shortcuts';
+import { RepoState, RepoStatus, status, StatusState } from './status';
+import { symbol, SymbolState } from './symbol';
+
+export { RepoState, RepoStatus };
+
+export interface RootState {
+ repository: RepositoryState;
+ search: SearchState;
+ file: FileState;
+ symbol: SymbolState;
+ editor: EditorState;
+ route: RouteState;
+ status: StatusState;
+ commit: CommitState;
+ blame: BlameState;
+ languageServer: LanguageServerState;
+ shortcuts: ShortcutsState;
+ setup: SetupState;
+}
+
+const reducers = {
+ repository,
+ file,
+ symbol,
+ editor,
+ search,
+ route,
+ status,
+ commit,
+ blame,
+ languageServer,
+ shortcuts,
+ setup,
+};
+
+// @ts-ignore
+export const rootReducer = combineReducers(reducers);
diff --git a/x-pack/plugins/code/public/reducers/language_server.ts b/x-pack/plugins/code/public/reducers/language_server.ts
new file mode 100644
index 0000000000000..4e96c34772231
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/language_server.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import produce from 'immer';
+import { Action, handleActions } from 'redux-actions';
+
+import { LanguageServer, LanguageServerStatus } from '../../common/language_server';
+import {
+ loadLanguageServers,
+ loadLanguageServersFailed,
+ loadLanguageServersSuccess,
+ requestInstallLanguageServer,
+ requestInstallLanguageServerSuccess,
+} from '../actions/language_server';
+
+export interface LanguageServerState {
+ languageServers: LanguageServer[];
+ loading: boolean;
+ installServerLoading: { [ls: string]: boolean };
+}
+
+const initialState: LanguageServerState = {
+ languageServers: [],
+ loading: false,
+ installServerLoading: {},
+};
+
+export const languageServer = handleActions(
+ {
+ [String(loadLanguageServers)]: (state: LanguageServerState, action: Action) =>
+ produce(state, draft => {
+ draft.loading = true;
+ }),
+ [String(loadLanguageServersSuccess)]: (state: LanguageServerState, action: Action) =>
+ produce(state, draft => {
+ draft.languageServers = action.payload;
+ draft.loading = false;
+ }),
+ [String(loadLanguageServersFailed)]: (state: LanguageServerState, action: Action) =>
+ produce(state, draft => {
+ draft.languageServers = [];
+ draft.loading = false;
+ }),
+ [String(requestInstallLanguageServer)]: (state: LanguageServerState, action: Action) =>
+ produce(state, draft => {
+ draft.installServerLoading[action.payload!] = true;
+ }),
+ [String(requestInstallLanguageServerSuccess)]: (
+ state: LanguageServerState,
+ action: Action
+ ) =>
+ produce(state, (draft: LanguageServerState) => {
+ draft.installServerLoading[action.payload!] = false;
+ draft.languageServers.find(ls => ls.name === action.payload)!.status =
+ LanguageServerStatus.READY;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/repository.ts b/x-pack/plugins/code/public/reducers/repository.ts
new file mode 100644
index 0000000000000..6accf51345c16
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/repository.ts
@@ -0,0 +1,129 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import produce from 'immer';
+import { Action, handleActions } from 'redux-actions';
+
+import { Repository, RepositoryConfig } from '../../model';
+
+import { RepoConfigs } from '../../model/workspace';
+import {
+ closeToast,
+ deleteRepoFinished,
+ fetchRepoConfigSuccess,
+ fetchRepos,
+ fetchReposFailed,
+ fetchReposSuccess,
+ importRepo,
+ importRepoFailed,
+ importRepoSuccess,
+ loadConfigsSuccess,
+ loadRepoSuccess,
+ loadRepoFailed,
+} from '../actions';
+
+export enum ToastType {
+ danger = 'danger',
+ success = 'success',
+ warning = 'warning',
+}
+
+export interface RepositoryState {
+ repositories: Repository[];
+ error?: Error;
+ loading: boolean;
+ importLoading: boolean;
+ repoConfigs?: RepoConfigs;
+ showToast: boolean;
+ toastMessage?: string;
+ toastType?: ToastType;
+ projectConfigs: { [key: string]: RepositoryConfig };
+ currentRepository?: Repository;
+}
+
+const initialState: RepositoryState = {
+ repositories: [],
+ loading: false,
+ importLoading: false,
+ showToast: false,
+ projectConfigs: {},
+};
+
+export const repository = handleActions(
+ {
+ [String(fetchRepos)]: (state: RepositoryState) =>
+ produce(state, draft => {
+ draft.loading = true;
+ }),
+ [String(fetchReposSuccess)]: (state: RepositoryState, action: Action) =>
+ produce(state, draft => {
+ draft.loading = false;
+ draft.repositories = action.payload || [];
+ }),
+ [String(fetchReposFailed)]: (state: RepositoryState, action: Action) => {
+ if (action.payload) {
+ return produce(state, draft => {
+ draft.error = action.payload;
+ draft.loading = false;
+ });
+ } else {
+ return state;
+ }
+ },
+ [String(deleteRepoFinished)]: (state: RepositoryState, action: Action) =>
+ produce(state, (draft: RepositoryState) => {
+ draft.repositories = state.repositories.filter(repo => repo.uri !== action.payload);
+ }),
+ [String(importRepo)]: (state: RepositoryState) =>
+ produce(state, draft => {
+ draft.importLoading = true;
+ }),
+ [String(importRepoSuccess)]: (state: RepositoryState, action: Action) =>
+ produce(state, (draft: RepositoryState) => {
+ draft.importLoading = false;
+ draft.showToast = true;
+ draft.toastType = ToastType.success;
+ draft.toastMessage = `${action.payload.name} has been successfully submitted!`;
+ draft.repositories = [...state.repositories, action.payload];
+ }),
+ [String(importRepoFailed)]: (state: RepositoryState, action: Action) =>
+ produce(state, draft => {
+ if (action.payload) {
+ if (action.payload.res.status === 304) {
+ draft.toastMessage = 'This Repository has already been imported!';
+ draft.showToast = true;
+ draft.toastType = ToastType.warning;
+ draft.importLoading = false;
+ } else {
+ draft.toastMessage = action.payload.body.message;
+ draft.showToast = true;
+ draft.toastType = ToastType.danger;
+ draft.importLoading = false;
+ }
+ }
+ }),
+ [String(closeToast)]: (state: RepositoryState, action: Action) =>
+ produce(state, draft => {
+ draft.showToast = false;
+ }),
+ [String(fetchRepoConfigSuccess)]: (state: RepositoryState, action: Action) =>
+ produce(state, draft => {
+ draft.repoConfigs = action.payload;
+ }),
+ [String(loadConfigsSuccess)]: (state: RepositoryState, action: Action) =>
+ produce(state, draft => {
+ draft.projectConfigs = action.payload;
+ }),
+ [String(loadRepoSuccess)]: (state: RepositoryState, action: Action) =>
+ produce(state, draft => {
+ draft.currentRepository = action.payload;
+ }),
+ [String(loadRepoFailed)]: (state: RepositoryState, action: Action) =>
+ produce(state, draft => {
+ draft.currentRepository = undefined;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/route.ts b/x-pack/plugins/code/public/reducers/route.ts
new file mode 100644
index 0000000000000..74857074ca66b
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/route.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import produce from 'immer';
+import { Action, handleActions } from 'redux-actions';
+
+import { routeChange } from '../actions';
+
+export interface RouteState {
+ match: any;
+}
+
+const initialState: RouteState = {
+ match: {},
+};
+
+export const route = handleActions(
+ {
+ [String(routeChange)]: (state: RouteState, action: Action) =>
+ produce(state, draft => {
+ draft.match = action.payload;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/search.ts b/x-pack/plugins/code/public/reducers/search.ts
new file mode 100644
index 0000000000000..7a6f56a9423f8
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/search.ts
@@ -0,0 +1,184 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import produce from 'immer';
+
+import { Action, handleActions } from 'redux-actions';
+
+import { DocumentSearchResult, RepositoryUri, SearchScope } from '../../model';
+import {
+ changeSearchScope,
+ documentSearch as documentSearchQuery,
+ documentSearchFailed,
+ DocumentSearchPayload,
+ documentSearchSuccess,
+ repositorySearch as repositorySearchAction,
+ repositorySearchFailed,
+ RepositorySearchPayload,
+ repositorySearchSuccess,
+ saveSearchOptions,
+ SearchOptions,
+ searchReposForScope,
+ searchReposForScopeSuccess,
+ turnOnDefaultRepoScope,
+} from '../actions';
+
+export interface SearchState {
+ scope: SearchScope;
+ query: string;
+ page?: number;
+ languages?: Set;
+ repositories?: Set;
+ isLoading: boolean;
+ isScopeSearchLoading: boolean;
+ error?: Error;
+ documentSearchResults?: DocumentSearchResult;
+ repositorySearchResults?: any;
+ searchOptions: SearchOptions;
+ scopeSearchResults: { repositories: any[] };
+}
+
+const initialState: SearchState = {
+ query: '',
+ isLoading: false,
+ isScopeSearchLoading: false,
+ scope: SearchScope.DEFAULT,
+ searchOptions: { repoScope: [], defaultRepoScopeOn: false },
+ scopeSearchResults: { repositories: [] },
+};
+
+export const search = handleActions(
+ {
+ [String(changeSearchScope)]: (state: SearchState, action: Action) =>
+ produce(state, draft => {
+ if (Object.values(SearchScope).includes(action.payload)) {
+ draft.scope = action.payload;
+ } else {
+ draft.scope = SearchScope.DEFAULT;
+ }
+ draft.isLoading = false;
+ }),
+ [String(documentSearchQuery)]: (state: SearchState, action: Action) =>
+ produce(state, draft => {
+ if (action.payload) {
+ draft.query = action.payload.query;
+ draft.page = parseInt(action.payload.page as string, 10);
+ if (action.payload.languages) {
+ draft.languages = new Set(decodeURIComponent(action.payload.languages).split(','));
+ } else {
+ draft.languages = new Set();
+ }
+ if (action.payload.repositories) {
+ draft.repositories = new Set(
+ decodeURIComponent(action.payload.repositories).split(',')
+ );
+ } else {
+ draft.repositories = new Set();
+ }
+ draft.isLoading = true;
+ draft.error = undefined;
+ }
+ }),
+ [String(documentSearchSuccess)]: (state: SearchState, action: Action) =>
+ produce(state, (draft: SearchState) => {
+ const {
+ from,
+ page,
+ totalPage,
+ results,
+ total,
+ repoAggregations,
+ langAggregations,
+ took,
+ } = action.payload!;
+ draft.isLoading = false;
+
+ const repoStats = repoAggregations!.map(agg => {
+ return {
+ name: agg.key,
+ value: agg.doc_count,
+ };
+ });
+
+ const languageStats = langAggregations!.map(agg => {
+ return {
+ name: agg.key,
+ value: agg.doc_count,
+ };
+ });
+
+ draft.documentSearchResults = {
+ ...draft.documentSearchResults,
+ query: state.query,
+ total,
+ took,
+ stats: {
+ total,
+ from: from! + 1,
+ to: from! + results!.length,
+ page: page!,
+ totalPage: totalPage!,
+ repoStats,
+ languageStats,
+ },
+ results,
+ };
+ }),
+ [String(documentSearchFailed)]: (state: SearchState, action: Action) => {
+ if (action.payload) {
+ return produce(state, draft => {
+ draft.isLoading = false;
+ draft.error = action.payload!;
+ });
+ } else {
+ return state;
+ }
+ },
+ [String(repositorySearchAction)]: (
+ state: SearchState,
+ action: Action
+ ) =>
+ produce(state, draft => {
+ if (action.payload) {
+ draft.query = action.payload.query;
+ draft.isLoading = true;
+ }
+ }),
+ [String(repositorySearchSuccess)]: (state: SearchState, action: Action) =>
+ produce(state, draft => {
+ draft.repositorySearchResults = action.payload;
+ draft.isLoading = false;
+ }),
+ [String(repositorySearchFailed)]: (state: SearchState, action: Action) => {
+ if (action.payload) {
+ return produce(state, draft => {
+ draft.isLoading = false;
+ draft.error = action.payload.error;
+ });
+ } else {
+ return state;
+ }
+ },
+ [String(saveSearchOptions)]: (state: SearchState, action: Action) =>
+ produce(state, draft => {
+ draft.searchOptions = action.payload;
+ }),
+ [String(searchReposForScope)]: (state: SearchState, action: Action) =>
+ produce(state, draft => {
+ draft.isScopeSearchLoading = true;
+ }),
+ [String(searchReposForScopeSuccess)]: (state: SearchState, action: Action) =>
+ produce(state, draft => {
+ draft.scopeSearchResults = action.payload;
+ draft.isScopeSearchLoading = false;
+ }),
+ [String(turnOnDefaultRepoScope)]: (state: SearchState, action: Action) =>
+ produce(state, draft => {
+ draft.searchOptions.defaultRepoScopeOn = true;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/setup.ts b/x-pack/plugins/code/public/reducers/setup.ts
new file mode 100644
index 0000000000000..7d4d3ca7d39af
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/setup.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import produce from 'immer';
+import { handleActions } from 'redux-actions';
+
+import { checkSetupFailed, checkSetupSuccess } from '../actions';
+
+export interface SetupState {
+ ok?: boolean;
+}
+
+const initialState: SetupState = {};
+
+export const setup = handleActions(
+ {
+ [String(checkSetupFailed)]: (state: SetupState) =>
+ produce(state, draft => {
+ draft.ok = false;
+ }),
+ [String(checkSetupSuccess)]: (state: SetupState) =>
+ produce(state, draft => {
+ draft.ok = true;
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/shortcuts.ts b/x-pack/plugins/code/public/reducers/shortcuts.ts
new file mode 100644
index 0000000000000..8829a024e1ddd
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/shortcuts.ts
@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import produce from 'immer';
+import { Action, handleActions } from 'redux-actions';
+
+import { registerShortcut, toggleHelp, unregisterShortcut } from '../actions';
+import { HotKey } from '../components/shortcuts';
+
+export interface ShortcutsState {
+ showHelp: boolean;
+ shortcuts: HotKey[];
+}
+
+const helpShortcut: HotKey = {
+ key: '?',
+ help: 'Display this page',
+ modifier: new Map(),
+ onPress: dispatch => {
+ dispatch(toggleHelp(null));
+ },
+};
+
+const initialState: ShortcutsState = {
+ showHelp: false,
+ shortcuts: [helpShortcut],
+};
+
+export const shortcuts = handleActions(
+ {
+ [String(toggleHelp)]: (state: ShortcutsState, action: Action) =>
+ produce(state, (draft: ShortcutsState) => {
+ if (action.payload === null) {
+ draft.showHelp = !state.showHelp;
+ } else {
+ draft.showHelp = action.payload!;
+ }
+ }),
+ [String(registerShortcut)]: (state: ShortcutsState, action: Action) =>
+ produce(state, (draft: ShortcutsState) => {
+ const hotKey = action.payload as HotKey;
+ draft.shortcuts.push(hotKey);
+ }),
+ [String(unregisterShortcut)]: (state: ShortcutsState, action: Action) =>
+ produce(state, (draft: ShortcutsState) => {
+ const hotKey = action.payload as HotKey;
+ const idx = state.shortcuts.indexOf(hotKey);
+ if (idx >= 0) {
+ draft.shortcuts.splice(idx, 1);
+ }
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/status.ts b/x-pack/plugins/code/public/reducers/status.ts
new file mode 100644
index 0000000000000..550185bdcd57d
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/status.ts
@@ -0,0 +1,184 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import produce from 'immer';
+import { handleActions } from 'redux-actions';
+
+import { RepositoryUri, WorkerReservedProgress } from '../../model';
+import {
+ loadStatus,
+ loadStatusFailed,
+ loadStatusSuccess,
+ updateCloneProgress,
+ updateDeleteProgress,
+ updateIndexProgress,
+} from '../actions/status';
+
+export enum RepoState {
+ CLONING,
+ DELETING,
+ INDEXING,
+ READY,
+ CLONE_ERROR,
+ DELETE_ERROR,
+ INDEX_ERROR,
+}
+
+export interface RepoStatus {
+ repoUri: string;
+ progress: number;
+ cloneProgress?: any;
+ timestamp?: Date;
+ state?: RepoState;
+ errorMessage?: string;
+}
+
+export interface StatusState {
+ status: { [key: string]: RepoStatus };
+ loading: boolean;
+ error?: Error;
+}
+
+const initialState: StatusState = {
+ status: {},
+ loading: false,
+};
+
+export const status = handleActions(
+ {
+ [String(loadStatus)]: (state: StatusState) =>
+ produce(state, draft => {
+ draft.loading = true;
+ }),
+ [String(loadStatusSuccess)]: (state: StatusState, action: any) =>
+ produce(state, draft => {
+ Object.keys(action.payload).forEach((repoUri: RepositoryUri) => {
+ const statuses = action.payload[repoUri];
+ if (statuses.deleteStatus) {
+ // 1. Look into delete status first
+ const progress = statuses.deleteStatus.progress;
+ if (
+ progress === WorkerReservedProgress.ERROR ||
+ progress === WorkerReservedProgress.TIMEOUT
+ ) {
+ draft.status[repoUri] = {
+ ...statuses.deleteStatus,
+ state: RepoState.DELETE_ERROR,
+ };
+ } else if (progress < WorkerReservedProgress.COMPLETED) {
+ draft.status[repoUri] = {
+ ...statuses.deleteStatus,
+ state: RepoState.DELETING,
+ };
+ }
+ } else if (statuses.indexStatus) {
+ const progress = statuses.indexStatus.progress;
+ if (
+ progress === WorkerReservedProgress.ERROR ||
+ progress === WorkerReservedProgress.TIMEOUT
+ ) {
+ draft.status[repoUri] = {
+ ...statuses.indexStatus,
+ state: RepoState.INDEX_ERROR,
+ };
+ } else if (progress < WorkerReservedProgress.COMPLETED) {
+ draft.status[repoUri] = {
+ ...statuses.indexStatus,
+ state: RepoState.INDEXING,
+ };
+ } else if (progress === WorkerReservedProgress.COMPLETED) {
+ draft.status[repoUri] = {
+ ...statuses.indexStatus,
+ state: RepoState.READY,
+ };
+ }
+ } else if (statuses.gitStatus) {
+ const progress = statuses.gitStatus.progress;
+ if (
+ progress === WorkerReservedProgress.ERROR ||
+ progress === WorkerReservedProgress.TIMEOUT
+ ) {
+ draft.status[repoUri] = {
+ ...statuses.gitStatus,
+ state: RepoState.CLONE_ERROR,
+ };
+ } else if (progress < WorkerReservedProgress.COMPLETED) {
+ draft.status[repoUri] = {
+ ...statuses.gitStatus,
+ state: RepoState.CLONING,
+ };
+ } else if (progress === WorkerReservedProgress.COMPLETED) {
+ draft.status[repoUri] = {
+ ...statuses.gitStatus,
+ state: RepoState.READY,
+ };
+ }
+ }
+ });
+ draft.loading = false;
+ }),
+ [String(loadStatusFailed)]: (state: StatusState, action: any) =>
+ produce(state, draft => {
+ draft.loading = false;
+ draft.error = action.payload;
+ }),
+ [String(updateCloneProgress)]: (state: StatusState, action: any) =>
+ produce(state, draft => {
+ const progress = action.payload.progress;
+ let s = RepoState.CLONING;
+ if (
+ progress === WorkerReservedProgress.ERROR ||
+ progress === WorkerReservedProgress.TIMEOUT
+ ) {
+ s = RepoState.CLONE_ERROR;
+ } else if (progress === WorkerReservedProgress.COMPLETED) {
+ s = RepoState.READY;
+ }
+ draft.status[action.payload.repoUri] = {
+ ...action.payload,
+ state: s,
+ };
+ }),
+ [String(updateIndexProgress)]: (state: StatusState, action: any) =>
+ produce(state, draft => {
+ const progress = action.payload.progress;
+ let s = RepoState.INDEXING;
+ if (
+ progress === WorkerReservedProgress.ERROR ||
+ progress === WorkerReservedProgress.TIMEOUT
+ ) {
+ s = RepoState.INDEX_ERROR;
+ } else if (progress === WorkerReservedProgress.COMPLETED) {
+ s = RepoState.READY;
+ }
+ draft.status[action.payload.repoUri] = {
+ ...action.payload,
+ state: s,
+ };
+ }),
+ [String(updateDeleteProgress)]: (state: StatusState, action: any) =>
+ produce(state, draft => {
+ const progress = action.payload.progress;
+ if (progress === WorkerReservedProgress.COMPLETED) {
+ delete draft.status[action.payload.repoUri];
+ } else {
+ let s = RepoState.DELETING;
+ if (
+ progress === WorkerReservedProgress.ERROR ||
+ progress === WorkerReservedProgress.TIMEOUT
+ ) {
+ s = RepoState.DELETE_ERROR;
+ }
+
+ draft.status[action.payload.repoUri] = {
+ ...action.payload,
+ state: s,
+ };
+ }
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/reducers/symbol.ts b/x-pack/plugins/code/public/reducers/symbol.ts
new file mode 100644
index 0000000000000..116171c3f8186
--- /dev/null
+++ b/x-pack/plugins/code/public/reducers/symbol.ts
@@ -0,0 +1,162 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import produce from 'immer';
+import _ from 'lodash';
+import { Action, handleActions } from 'redux-actions';
+
+import { SymbolInformation } from 'vscode-languageserver-types/lib/esm/main';
+import {
+ closeSymbolPath,
+ loadStructure,
+ loadStructureFailed,
+ loadStructureSuccess,
+ openSymbolPath,
+ SymbolsPayload,
+} from '../actions';
+
+const SPECIAL_SYMBOL_NAME = '{...}';
+const SPECIAL_CONTAINER_NAME = '';
+
+export interface SymbolWithMembers extends SymbolInformation {
+ members?: SymbolWithMembers[];
+ path?: string;
+}
+
+type Container = SymbolWithMembers | undefined;
+
+export interface SymbolState {
+ symbols: { [key: string]: SymbolInformation[] };
+ structureTree: { [key: string]: SymbolWithMembers[] };
+ error?: Error;
+ loading: boolean;
+ lastRequestPath?: string;
+ closedPaths: string[];
+}
+
+const initialState: SymbolState = {
+ symbols: {},
+ loading: false,
+ structureTree: {},
+ closedPaths: [],
+};
+
+const sortSymbol = (a: SymbolWithMembers, b: SymbolWithMembers) => {
+ const lineDiff = a.location.range.start.line - b.location.range.start.line;
+ if (lineDiff === 0) {
+ return a.location.range.start.character - b.location.range.start.character;
+ } else {
+ return lineDiff;
+ }
+};
+
+const generateStructureTree: (symbols: SymbolInformation[]) => any = symbols => {
+ const structureTree: SymbolWithMembers[] = [];
+
+ function findContainer(
+ tree: SymbolWithMembers[],
+ containerName?: string
+ ): SymbolInformation | undefined {
+ if (containerName === undefined) {
+ return undefined;
+ }
+ const regex = new RegExp(`^${containerName}[<(]?.*[>)]?$`);
+ const result = tree.find((s: SymbolInformation) => {
+ return regex.test(s.name);
+ });
+ if (result) {
+ return result;
+ } else {
+ const subTree = tree
+ .filter(s => s.members)
+ .map(s => s.members)
+ .flat();
+ if (subTree.length > 0) {
+ return findContainer(subTree, containerName);
+ } else {
+ return undefined;
+ }
+ }
+ }
+
+ symbols
+ .sort(sortSymbol)
+ .forEach((s: SymbolInformation, index: number, arr: SymbolInformation[]) => {
+ let container: Container;
+ /**
+ * For Enum class in Java, the container name and symbol name that LSP gives are special.
+ * For more information, see https://github.com/elastic/codesearch/issues/580
+ */
+ if (s.containerName === SPECIAL_CONTAINER_NAME) {
+ container = _.findLast(
+ arr.slice(0, index),
+ (sy: SymbolInformation) => sy.name === SPECIAL_SYMBOL_NAME
+ );
+ } else {
+ container = findContainer(structureTree, s.containerName);
+ }
+ if (container) {
+ if (!container.path) {
+ container.path = container.name;
+ }
+ if (container.members) {
+ container.members.push({ ...s, path: `${container.path}/${s.name}` });
+ } else {
+ container.members = [{ ...s, path: `${container.path}/${s.name}` }];
+ }
+ } else {
+ structureTree.push({ ...s, path: s.name });
+ }
+ });
+
+ return structureTree;
+};
+
+export const symbol = handleActions(
+ {
+ [String(loadStructure)]: (state: SymbolState, action: Action) =>
+ produce(state, draft => {
+ draft.loading = true;
+ draft.lastRequestPath = action.payload || '';
+ }),
+ [String(loadStructureSuccess)]: (state: SymbolState, action: Action) =>
+ produce(state, (draft: SymbolState) => {
+ draft.loading = false;
+ const { path, data } = action.payload!;
+ draft.structureTree[path] = generateStructureTree(data);
+ draft.symbols = {
+ ...state.symbols,
+ [path]: data,
+ };
+ draft.error = undefined;
+ }),
+ [String(loadStructureFailed)]: (state: SymbolState, action: Action) => {
+ if (action.payload) {
+ return produce(state, draft => {
+ draft.loading = false;
+ draft.error = action.payload;
+ });
+ } else {
+ return state;
+ }
+ },
+ [String(closeSymbolPath)]: (state: SymbolState, action: any) =>
+ produce(state, (draft: SymbolState) => {
+ const path = action.payload!;
+ if (!state.closedPaths.includes(path)) {
+ draft.closedPaths.push(path);
+ }
+ }),
+ [String(openSymbolPath)]: (state: SymbolState, action: any) =>
+ produce(state, draft => {
+ const idx = state.closedPaths.indexOf(action.payload!);
+ if (idx >= 0) {
+ draft.closedPaths.splice(idx, 1);
+ }
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/plugins/code/public/sagas/blame.ts b/x-pack/plugins/code/public/sagas/blame.ts
new file mode 100644
index 0000000000000..d79221f9c63ef
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/blame.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Action } from 'redux-actions';
+import { kfetch } from 'ui/kfetch';
+import { call, put, takeEvery } from 'redux-saga/effects';
+import { Match } from '../actions';
+import { loadBlame, loadBlameFailed, LoadBlamePayload, loadBlameSuccess } from '../actions/blame';
+import { blamePattern } from './patterns';
+
+function requestBlame(repoUri: string, revision: string, path: string) {
+ return kfetch({
+ pathname: `/api/code/repo/${repoUri}/blame/${encodeURIComponent(revision)}/${path}`,
+ });
+}
+
+function* handleFetchBlame(action: Action) {
+ try {
+ const { repoUri, revision, path } = action.payload!;
+ const blame = yield call(requestBlame, repoUri, revision, path);
+ yield put(loadBlameSuccess(blame));
+ } catch (err) {
+ yield put(loadBlameFailed(err));
+ }
+}
+
+export function* watchLoadBlame() {
+ yield takeEvery(String(loadBlame), handleFetchBlame);
+}
+
+function* handleBlame(action: Action) {
+ const { resource, org, repo, revision, path } = action.payload!.params;
+ const repoUri = `${resource}/${org}/${repo}`;
+ yield put(loadBlame({ repoUri, revision, path }));
+}
+
+export function* watchBlame() {
+ yield takeEvery(blamePattern, handleBlame);
+}
diff --git a/x-pack/plugins/code/public/sagas/commit.ts b/x-pack/plugins/code/public/sagas/commit.ts
new file mode 100644
index 0000000000000..4235f047865fa
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/commit.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { Action } from 'redux-actions';
+import { kfetch } from 'ui/kfetch';
+import { call, put, takeEvery } from 'redux-saga/effects';
+import { loadCommit, loadCommitFailed, loadCommitSuccess, Match } from '../actions';
+import { commitRoutePattern } from './patterns';
+
+function requestCommit(repo: string, commitId: string) {
+ return kfetch({
+ pathname: `/api/code/repo/${repo}/diff/${commitId}`,
+ });
+}
+
+function* handleLoadCommit(action: Action) {
+ try {
+ const { commitId, resource, org, repo } = action.payload!.params;
+ yield put(loadCommit(commitId));
+ const repoUri = `${resource}/${org}/${repo}`;
+ const commit = yield call(requestCommit, repoUri, commitId);
+ yield put(loadCommitSuccess(commit));
+ } catch (err) {
+ yield put(loadCommitFailed(err));
+ }
+}
+
+export function* watchLoadCommit() {
+ yield takeEvery(commitRoutePattern, handleLoadCommit);
+}
diff --git a/x-pack/plugins/code/public/sagas/editor.ts b/x-pack/plugins/code/public/sagas/editor.ts
new file mode 100644
index 0000000000000..e29a2f5ff19e6
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/editor.ts
@@ -0,0 +1,213 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import queryString from 'querystring';
+import { Action } from 'redux-actions';
+import { kfetch } from 'ui/kfetch';
+import { TextDocumentPositionParams } from 'vscode-languageserver';
+import { call, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
+import { parseGoto, parseLspUrl, toCanonicalUrl } from '../../common/uri_util';
+import { FileTree } from '../../model';
+import {
+ closeReferences,
+ fetchFile,
+ FetchFileResponse,
+ fetchRepoBranches,
+ fetchRepoCommits,
+ fetchRepoTree,
+ fetchTreeCommits,
+ findReferences,
+ findReferencesFailed,
+ findReferencesSuccess,
+ loadStructure,
+ Match,
+ resetRepoTree,
+ revealPosition,
+ fetchRepos,
+ turnOnDefaultRepoScope,
+} from '../actions';
+import { loadRepo, loadRepoFailed, loadRepoSuccess } from '../actions/status';
+import { PathTypes } from '../common/types';
+import { RootState } from '../reducers';
+import { getPathOfTree } from '../reducers/file';
+import {
+ fileSelector,
+ getTree,
+ lastRequestPathSelector,
+ refUrlSelector,
+ repoScopeSelector,
+} from '../selectors';
+import { history } from '../utils/url';
+import { mainRoutePattern } from './patterns';
+
+function* handleReferences(action: Action) {
+ try {
+ const params: TextDocumentPositionParams = action.payload!;
+ const { title, files } = yield call(requestFindReferences, params);
+ const repos = Object.keys(files).map((repo: string) => ({ repo, files: files[repo] }));
+ yield put(findReferencesSuccess({ title, repos }));
+ } catch (error) {
+ yield put(findReferencesFailed(error));
+ }
+}
+
+function requestFindReferences(params: TextDocumentPositionParams) {
+ return kfetch({
+ pathname: `/api/code/lsp/findReferences`,
+ method: 'POST',
+ body: JSON.stringify(params),
+ });
+}
+
+export function* watchLspMethods() {
+ yield takeLatest(String(findReferences), handleReferences);
+}
+
+function handleCloseReferences(action: Action) {
+ if (action.payload) {
+ const { pathname, search } = history.location;
+ const queryParams = queryString.parse(search);
+ if (queryParams.tab) {
+ delete queryParams.tab;
+ }
+ if (queryParams.refUrl) {
+ delete queryParams.refUrl;
+ }
+ const query = queryString.stringify(queryParams);
+ if (query) {
+ history.push(`${pathname}?${query}`);
+ } else {
+ history.push(pathname);
+ }
+ }
+}
+
+export function* watchCloseReference() {
+ yield takeLatest(String(closeReferences), handleCloseReferences);
+}
+
+function* handleReference(url: string) {
+ const refUrl = yield select(refUrlSelector);
+ if (refUrl === url) {
+ return;
+ }
+ const { uri, position, schema, repoUri, file, revision } = parseLspUrl(url);
+ if (uri && position) {
+ yield put(
+ findReferences({
+ textDocument: {
+ uri: toCanonicalUrl({ revision, schema, repoUri, file }),
+ },
+ position,
+ })
+ );
+ }
+}
+
+function* handleFile(repoUri: string, file: string, revision: string) {
+ const response: FetchFileResponse = yield select(fileSelector);
+ const payload = response && response.payload;
+ if (
+ payload &&
+ payload.path === file &&
+ payload.revision === revision &&
+ payload.uri === repoUri
+ ) {
+ return;
+ }
+ yield put(
+ fetchFile({
+ uri: repoUri,
+ path: file,
+ revision,
+ })
+ );
+}
+
+function fetchRepo(repoUri: string) {
+ return kfetch({ pathname: `/api/code/repo/${repoUri}` });
+}
+
+function* loadRepoSaga(action: any) {
+ try {
+ const repo = yield call(fetchRepo, action.payload);
+ yield put(loadRepoSuccess(repo));
+ } catch (e) {
+ yield put(loadRepoFailed(e));
+ }
+}
+
+export function* watchLoadRepo() {
+ yield takeEvery(String(loadRepo), loadRepoSaga);
+}
+
+function* handleMainRouteChange(action: Action) {
+ // in source view page, we need repos as default repo scope options when no query input
+ yield put(fetchRepos());
+ // turn on defaultRepoScope if there's no repo scope specified when enter a source view page
+ const repoScope = yield select(repoScopeSelector);
+ if (repoScope.length === 0) {
+ yield put(turnOnDefaultRepoScope());
+ }
+ const { location } = action.payload!;
+ const search = location.search.startsWith('?') ? location.search.substring(1) : location.search;
+ const queryParams = queryString.parse(search);
+ const { resource, org, repo, path: file, pathType, revision, goto } = action.payload!.params;
+ const repoUri = `${resource}/${org}/${repo}`;
+ let position;
+ if (goto) {
+ position = parseGoto(goto);
+ }
+ yield put(loadRepo(repoUri));
+ yield put(fetchRepoBranches({ uri: repoUri }));
+ if (file) {
+ if ([PathTypes.blob, PathTypes.blame].includes(pathType as PathTypes)) {
+ yield call(handleFile, repoUri, file, revision);
+ yield put(revealPosition(position));
+ const { tab, refUrl } = queryParams;
+ if (tab === 'references' && refUrl) {
+ yield call(handleReference, decodeURIComponent(refUrl as string));
+ } else {
+ yield put(closeReferences(false));
+ }
+ }
+ const commits = yield select((state: RootState) => state.file.treeCommits[file]);
+ if (commits === undefined) {
+ yield put(fetchTreeCommits({ revision, uri: repoUri, path: file }));
+ }
+ }
+ const lastRequestPath = yield select(lastRequestPathSelector);
+ const currentTree: FileTree = yield select(getTree);
+ // repo changed
+ if (currentTree.repoUri !== repoUri) {
+ yield put(resetRepoTree());
+ yield put(fetchRepoCommits({ uri: repoUri, revision }));
+ }
+ const tree = yield select(getTree);
+ yield put(
+ fetchRepoTree({
+ uri: repoUri,
+ revision,
+ path: file || '',
+ parents: getPathOfTree(tree, (file || '').split('/')) === null,
+ isDir: pathType === PathTypes.tree,
+ })
+ );
+ const uri = toCanonicalUrl({
+ repoUri,
+ file,
+ revision,
+ });
+ if (file && pathType === PathTypes.blob) {
+ if (lastRequestPath !== uri) {
+ yield put(loadStructure(uri!));
+ }
+ }
+}
+
+export function* watchMainRouteChange() {
+ yield takeLatest(mainRoutePattern, handleMainRouteChange);
+}
diff --git a/x-pack/plugins/code/public/sagas/file.ts b/x-pack/plugins/code/public/sagas/file.ts
new file mode 100644
index 0000000000000..1d229bf552d3d
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/file.ts
@@ -0,0 +1,267 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Action } from 'redux-actions';
+import chrome from 'ui/chrome';
+import { kfetch } from 'ui/kfetch';
+import Url from 'url';
+import { call, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
+
+import { FileTree } from '../../model';
+import {
+ fetchDirectory,
+ fetchDirectoryFailed,
+ fetchDirectorySuccess,
+ fetchFile,
+ fetchFileFailed,
+ FetchFilePayload,
+ FetchFileResponse,
+ fetchFileSuccess,
+ fetchMoreCommits,
+ fetchRepoBranches,
+ fetchRepoBranchesFailed,
+ fetchRepoBranchesSuccess,
+ fetchRepoCommits,
+ fetchRepoCommitsFailed,
+ fetchRepoCommitsSuccess,
+ FetchRepoPayload,
+ FetchRepoPayloadWithRevision,
+ fetchRepoTree,
+ fetchRepoTreeFailed,
+ FetchRepoTreePayload,
+ fetchRepoTreeSuccess,
+ fetchTreeCommits,
+ fetchTreeCommitsFailed,
+ fetchTreeCommitsSuccess,
+ gotoRepo,
+ Match,
+ setNotFound,
+} from '../actions';
+import { RootState } from '../reducers';
+import { treeCommitsSelector } from '../selectors';
+import { repoRoutePattern } from './patterns';
+
+function* handleFetchRepoTree(action: Action) {
+ try {
+ const { uri, revision, path, parents, isDir } = action.payload!;
+ if (path) {
+ yield call(fetchPath, { uri, revision, path, parents, isDir });
+ } else {
+ yield call(fetchPath, action.payload!);
+ }
+ } catch (err) {
+ yield put(fetchRepoTreeFailed(err));
+ }
+}
+
+function* fetchPath(payload: FetchRepoTreePayload) {
+ const update: FileTree = yield call(requestRepoTree, payload);
+ (update.children || []).sort((a, b) => {
+ const typeDiff = a.type - b.type;
+ if (typeDiff === 0) {
+ return a.name > b.name ? 1 : -1;
+ } else {
+ return -typeDiff;
+ }
+ });
+ update.repoUri = payload.uri;
+ yield put(
+ fetchRepoTreeSuccess({ tree: update, path: payload.path, withParents: payload.parents })
+ );
+ return update;
+}
+
+interface FileTreeQuery {
+ parents?: boolean;
+ limit: number;
+ flatten: boolean;
+ [key: string]: string | number | boolean | undefined;
+}
+
+function requestRepoTree({
+ uri,
+ revision,
+ path,
+ limit = 50,
+ parents = false,
+}: FetchRepoTreePayload) {
+ const query: FileTreeQuery = { limit, flatten: true };
+ if (parents) {
+ query.parents = true;
+ }
+ return kfetch({
+ pathname: `/api/code/repo/${uri}/tree/${encodeURIComponent(revision)}/${path}`,
+ query,
+ });
+}
+
+export function* watchFetchRepoTree() {
+ yield takeEvery(String(fetchRepoTree), handleFetchRepoTree);
+}
+
+function* handleFetchBranches(action: Action) {
+ try {
+ const results = yield call(requestBranches, action.payload!);
+ yield put(fetchRepoBranchesSuccess(results));
+ } catch (err) {
+ yield put(fetchRepoBranchesFailed(err));
+ }
+}
+
+function requestBranches({ uri }: FetchRepoPayload) {
+ return kfetch({
+ pathname: `/api/code/repo/${uri}/references`,
+ });
+}
+
+function* handleFetchCommits(action: Action) {
+ try {
+ const results = yield call(requestCommits, action.payload!);
+ yield put(fetchRepoCommitsSuccess(results));
+ } catch (err) {
+ yield put(fetchRepoCommitsFailed(err));
+ }
+}
+
+function* handleFetchMoreCommits(action: Action) {
+ try {
+ const path = yield select((state: RootState) => state.file.currentPath);
+ const commits = yield select(treeCommitsSelector);
+ const revision = commits.length > 0 ? commits[commits.length - 1].id : 'head';
+ const uri = action.payload;
+ // @ts-ignore
+ const newCommits = yield call(requestCommits, { uri, revision }, path, true);
+ yield put(fetchTreeCommitsSuccess({ path, commits: newCommits, append: true }));
+ } catch (err) {
+ yield put(fetchTreeCommitsFailed(err));
+ }
+}
+
+function* handleFetchTreeCommits(action: Action) {
+ try {
+ const path = action.payload!.path;
+ const commits = yield call(requestCommits, action.payload!, path);
+ yield put(fetchTreeCommitsSuccess({ path, commits }));
+ } catch (err) {
+ yield put(fetchTreeCommitsFailed(err));
+ }
+}
+
+function requestCommits(
+ { uri, revision }: FetchRepoPayloadWithRevision,
+ path?: string,
+ loadMore?: boolean,
+ count?: number
+) {
+ const pathStr = path ? `/${path}` : '';
+ const options: any = {
+ pathname: `/api/code/repo/${uri}/history/${encodeURIComponent(revision)}${pathStr}`,
+ };
+ if (loadMore) {
+ options.query = { after: 1 };
+ }
+ if (count) {
+ options.count = count;
+ }
+ return kfetch(options);
+}
+
+export async function requestFile(
+ payload: FetchFilePayload,
+ line?: string
+): Promise {
+ const { uri, revision, path } = payload;
+ const url = `/api/code/repo/${uri}/blob/${encodeURIComponent(revision)}/${path}`;
+ const query: any = {};
+ if (line) {
+ query.line = line;
+ }
+ const response: Response = await fetch(chrome.addBasePath(Url.format({ pathname: url, query })));
+
+ if (response.status >= 200 && response.status < 300) {
+ const contentType = response.headers.get('Content-Type');
+
+ if (contentType && contentType.startsWith('text/')) {
+ const lang = contentType.split(';')[0].substring('text/'.length);
+ if (lang === 'big') {
+ return {
+ payload,
+ content: '',
+ isOversize: true,
+ };
+ }
+ return {
+ payload,
+ lang,
+ content: await response.text(),
+ isUnsupported: false,
+ };
+ } else if (contentType && contentType.startsWith('image/')) {
+ return {
+ payload,
+ isImage: true,
+ content: '',
+ url,
+ isUnsupported: false,
+ };
+ } else {
+ return {
+ payload,
+ isImage: false,
+ content: '',
+ url,
+ isUnsupported: true,
+ };
+ }
+ } else if (response.status === 404) {
+ return {
+ payload,
+ isNotFound: true,
+ };
+ }
+ throw new Error('invalid file type');
+}
+
+function* handleFetchFile(action: Action) {
+ try {
+ const results = yield call(requestFile, action.payload!);
+ if (results.isNotFound) {
+ yield put(setNotFound(true));
+ yield put(fetchFileFailed(new Error('file not found')));
+ } else {
+ yield put(fetchFileSuccess(results));
+ }
+ } catch (err) {
+ yield put(fetchFileFailed(err));
+ }
+}
+
+function* handleFetchDirs(action: Action) {
+ try {
+ const dir = yield call(requestRepoTree, action.payload!);
+ yield put(fetchDirectorySuccess(dir));
+ } catch (err) {
+ yield fetchDirectoryFailed(err);
+ }
+}
+
+export function* watchFetchBranchesAndCommits() {
+ yield takeEvery(String(fetchRepoBranches), handleFetchBranches);
+ yield takeEvery(String(fetchRepoCommits), handleFetchCommits);
+ yield takeLatest(String(fetchFile), handleFetchFile);
+ yield takeEvery(String(fetchDirectory), handleFetchDirs);
+ yield takeLatest(String(fetchTreeCommits), handleFetchTreeCommits);
+ yield takeLatest(String(fetchMoreCommits), handleFetchMoreCommits);
+}
+
+function* handleRepoRouteChange(action: Action) {
+ const { url } = action.payload!;
+ yield put(gotoRepo(url));
+}
+
+export function* watchRepoRouteChange() {
+ yield takeEvery(repoRoutePattern, handleRepoRouteChange);
+}
diff --git a/x-pack/plugins/code/public/sagas/index.ts b/x-pack/plugins/code/public/sagas/index.ts
new file mode 100644
index 0000000000000..41219b79ac7be
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/index.ts
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { fork } from 'redux-saga/effects';
+
+import { watchBlame, watchLoadBlame } from './blame';
+import { watchLoadCommit } from './commit';
+import {
+ watchCloseReference,
+ watchLoadRepo,
+ watchLspMethods,
+ watchMainRouteChange,
+} from './editor';
+import { watchFetchBranchesAndCommits, watchFetchRepoTree, watchRepoRouteChange } from './file';
+import { watchInstallLanguageServer, watchLoadLanguageServers } from './language_server';
+import { watchLoadConfigs, watchSwitchProjectLanguageServer } from './project_config';
+import {
+ watchLoadRepoListStatus,
+ watchLoadRepoStatus,
+ watchRepoCloneStatusPolling,
+ watchRepoDeleteStatusPolling,
+ watchRepoIndexStatusPolling,
+} from './project_status';
+import {
+ watchAdminRouteChange,
+ watchDeleteRepo,
+ watchFetchRepoConfigs,
+ watchFetchRepos,
+ watchGotoRepo,
+ watchImportRepo,
+ watchIndexRepo,
+ watchInitRepoCmd,
+} from './repository';
+import {
+ watchDocumentSearch,
+ watchRepoScopeSearch,
+ watchRepositorySearch,
+ watchSearchRouteChange,
+} from './search';
+import { watchRootRoute } from './setup';
+import { watchRepoCloneSuccess, watchRepoDeleteFinished } from './status';
+import { watchLoadStructure } from './structure';
+
+export function* rootSaga() {
+ yield fork(watchRootRoute);
+ yield fork(watchLoadCommit);
+ yield fork(watchFetchRepos);
+ yield fork(watchDeleteRepo);
+ yield fork(watchIndexRepo);
+ yield fork(watchImportRepo);
+ yield fork(watchFetchRepoTree);
+ yield fork(watchFetchBranchesAndCommits);
+ yield fork(watchDocumentSearch);
+ yield fork(watchRepositorySearch);
+ yield fork(watchLoadStructure);
+ yield fork(watchLspMethods);
+ yield fork(watchCloseReference);
+ yield fork(watchFetchRepoConfigs);
+ yield fork(watchInitRepoCmd);
+ yield fork(watchGotoRepo);
+ yield fork(watchLoadRepo);
+ yield fork(watchSearchRouteChange);
+ yield fork(watchAdminRouteChange);
+ yield fork(watchMainRouteChange);
+ yield fork(watchLoadRepo);
+ yield fork(watchRepoRouteChange);
+ yield fork(watchLoadBlame);
+ yield fork(watchBlame);
+ yield fork(watchRepoCloneSuccess);
+ yield fork(watchRepoDeleteFinished);
+ yield fork(watchLoadLanguageServers);
+ yield fork(watchInstallLanguageServer);
+ yield fork(watchSwitchProjectLanguageServer);
+ yield fork(watchLoadConfigs);
+ yield fork(watchLoadRepoListStatus);
+ yield fork(watchLoadRepoStatus);
+ yield fork(watchRepoDeleteStatusPolling);
+ yield fork(watchRepoIndexStatusPolling);
+ yield fork(watchRepoCloneStatusPolling);
+ yield fork(watchRepoScopeSearch);
+}
diff --git a/x-pack/plugins/code/public/sagas/language_server.ts b/x-pack/plugins/code/public/sagas/language_server.ts
new file mode 100644
index 0000000000000..927892e3f49bb
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/language_server.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Action } from 'redux-actions';
+import { kfetch } from 'ui/kfetch';
+import { call, put, takeEvery } from 'redux-saga/effects';
+import {
+ loadLanguageServers,
+ loadLanguageServersFailed,
+ loadLanguageServersSuccess,
+ requestInstallLanguageServer,
+ requestInstallLanguageServerFailed,
+ requestInstallLanguageServerSuccess,
+} from '../actions/language_server';
+
+function fetchLangServers() {
+ return kfetch({
+ pathname: '/api/code/install',
+ });
+}
+
+function installLanguageServer(languageServer: string) {
+ return kfetch({
+ pathname: `/api/code/install/${languageServer}`,
+ method: 'POST',
+ });
+}
+
+function* handleInstallLanguageServer(action: Action) {
+ try {
+ yield call(installLanguageServer, action.payload!);
+ yield put(requestInstallLanguageServerSuccess(action.payload!));
+ } catch (err) {
+ yield put(requestInstallLanguageServerFailed(err));
+ }
+}
+
+function* handleLoadLanguageServer() {
+ try {
+ const langServers = yield call(fetchLangServers);
+ yield put(loadLanguageServersSuccess(langServers));
+ } catch (err) {
+ yield put(loadLanguageServersFailed(err));
+ }
+}
+
+export function* watchLoadLanguageServers() {
+ yield takeEvery(String(loadLanguageServers), handleLoadLanguageServer);
+}
+
+export function* watchInstallLanguageServer() {
+ yield takeEvery(String(requestInstallLanguageServer), handleInstallLanguageServer);
+}
diff --git a/x-pack/plugins/code/public/sagas/patterns.ts b/x-pack/plugins/code/public/sagas/patterns.ts
new file mode 100644
index 0000000000000..53a007491f5d1
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/patterns.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { Action } from 'redux-actions';
+import { Match, routeChange } from '../actions';
+import { PathTypes } from '../common/types';
+import * as ROUTES from '../components/routes';
+
+export const generatePattern = (path: string) => (action: Action) =>
+ action.type === String(routeChange) && action.payload!.path === path;
+export const rootRoutePattern = generatePattern(ROUTES.ROOT);
+export const setupRoutePattern = generatePattern(ROUTES.SETUP);
+export const adminRoutePattern = generatePattern(ROUTES.ADMIN);
+export const repoRoutePattern = generatePattern(ROUTES.REPO);
+export const mainRoutePattern = (action: Action) =>
+ action.type === String(routeChange) &&
+ (ROUTES.MAIN === action.payload!.path || ROUTES.MAIN_ROOT === action.payload!.path);
+export const searchRoutePattern = generatePattern(ROUTES.SEARCH);
+export const commitRoutePattern = generatePattern(ROUTES.DIFF);
+
+export const sourceFilePattern = (action: Action) =>
+ mainRoutePattern(action) && action.payload!.params.pathType === PathTypes.blob;
+
+export const blamePattern = (action: Action) =>
+ mainRoutePattern(action) && action.payload!.params.pathType === PathTypes.blame;
diff --git a/x-pack/plugins/code/public/sagas/project_config.ts b/x-pack/plugins/code/public/sagas/project_config.ts
new file mode 100644
index 0000000000000..fb1672f2c65ca
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/project_config.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Action } from 'redux-actions';
+import { kfetch } from 'ui/kfetch';
+import { all, call, put, takeEvery } from 'redux-saga/effects';
+import { Repository, RepositoryConfig } from '../../model';
+import {
+ fetchReposSuccess,
+ RepoConfigPayload,
+ switchLanguageServer,
+ switchLanguageServerFailed,
+ switchLanguageServerSuccess,
+} from '../actions';
+import { loadConfigsFailed, loadConfigsSuccess } from '../actions/project_config';
+
+function putProjectConfig(repoUri: string, config: RepositoryConfig) {
+ return kfetch({
+ pathname: `/api/code/repo/config/${repoUri}`,
+ method: 'PUT',
+ body: JSON.stringify(config),
+ });
+}
+
+function* switchProjectLanguageServer(action: Action) {
+ try {
+ const { repoUri, config } = action.payload!;
+ yield call(putProjectConfig, repoUri, config);
+ yield put(switchLanguageServerSuccess());
+ } catch (err) {
+ yield put(switchLanguageServerFailed(err));
+ }
+}
+
+export function* watchSwitchProjectLanguageServer() {
+ yield takeEvery(String(switchLanguageServer), switchProjectLanguageServer);
+}
+
+function fetchConfigs(repoUri: string) {
+ return kfetch({
+ pathname: `/api/code/repo/config/${repoUri}`,
+ });
+}
+
+function* loadConfigs(action: Action) {
+ try {
+ const repositories = action.payload!;
+ const promises = repositories.map(repo => call(fetchConfigs, repo.uri));
+ const configs = yield all(promises);
+ yield put(
+ loadConfigsSuccess(
+ configs.reduce((acc: { [k: string]: RepositoryConfig }, config: RepositoryConfig) => {
+ acc[config.uri] = config;
+ return acc;
+ }, {})
+ )
+ );
+ } catch (err) {
+ yield put(loadConfigsFailed(err));
+ }
+}
+
+export function* watchLoadConfigs() {
+ yield takeEvery(String(fetchReposSuccess), loadConfigs);
+}
diff --git a/x-pack/plugins/code/public/sagas/project_status.ts b/x-pack/plugins/code/public/sagas/project_status.ts
new file mode 100644
index 0000000000000..dea5b6629fd71
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/project_status.ts
@@ -0,0 +1,274 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import moment from 'moment';
+import { Action } from 'redux-actions';
+import { delay } from 'redux-saga';
+import { kfetch } from 'ui/kfetch';
+import { all, call, put, takeEvery, takeLatest } from 'redux-saga/effects';
+
+import { RepositoryUtils } from '../../common/repository_utils';
+import { Repository, RepositoryUri, WorkerReservedProgress } from '../../model';
+import {
+ deleteRepo,
+ fetchReposSuccess,
+ importRepo,
+ indexRepo,
+ loadRepoSuccess,
+ loadStatusFailed,
+ loadStatusSuccess,
+ pollRepoCloneStatus,
+ pollRepoDeleteStatus,
+ pollRepoIndexStatus,
+ updateCloneProgress,
+ updateDeleteProgress,
+ updateIndexProgress,
+} from '../actions';
+import { cloneCompletedPattern } from './status';
+
+function fetchStatus(repoUri: string) {
+ return kfetch({
+ pathname: `/api/code/repo/status/${repoUri}`,
+ });
+}
+
+function* loadRepoListStatus(repos: Repository[]) {
+ try {
+ const promises = repos.map(repo => call(fetchStatus, repo.uri));
+ const statuses = yield all(promises);
+ yield put(
+ loadStatusSuccess(
+ statuses.reduce((acc: { [k: string]: any }, status: any) => {
+ acc[status.gitStatus.uri] = status;
+ return acc;
+ }, {})
+ )
+ );
+ } catch (err) {
+ yield put(loadStatusFailed(err));
+ }
+}
+
+function* loadRepoStatus(repo: Repository) {
+ try {
+ const repoStatus = yield call(fetchStatus, repo.uri);
+ yield put(
+ loadStatusSuccess({
+ [repo.uri]: repoStatus,
+ })
+ );
+ } catch (err) {
+ yield put(loadStatusFailed(err));
+ }
+}
+
+function* handleRepoStatus(action: Action) {
+ const repository: Repository = action.payload!;
+ yield call(loadRepoStatus, repository);
+}
+
+function* handleRepoListStatus(action: Action) {
+ const repos: Repository[] = action.payload!;
+ yield call(loadRepoListStatus, repos);
+}
+
+function isInProgress(progress: number): boolean {
+ return progress < WorkerReservedProgress.COMPLETED && progress >= WorkerReservedProgress.INIT;
+}
+
+function* handleRepoListStatusLoaded(action: Action) {
+ const statuses = action.payload;
+ for (const repoUri of Object.keys(statuses)) {
+ const status = statuses[repoUri];
+ if (status.deleteStatus) {
+ yield put(pollRepoDeleteStatus(repoUri));
+ } else if (status.indexStatus) {
+ if (isInProgress(status.indexStatus.progress)) {
+ yield put(pollRepoIndexStatus(repoUri));
+ }
+ } else if (status.gitStatus) {
+ if (isInProgress(status.gitStatus.progress)) {
+ yield put(pollRepoCloneStatus(repoUri));
+ }
+ }
+ }
+}
+
+// `fetchReposSuccess` is issued by the repository admin page.
+export function* watchLoadRepoListStatus() {
+ yield takeEvery(String(fetchReposSuccess), handleRepoListStatus);
+ // After all the status of all the repositoriesin the list has been loaded,
+ // start polling status only for those still in progress.
+ yield takeEvery(String(loadStatusSuccess), handleRepoListStatusLoaded);
+}
+
+// `loadRepoSuccess` is issued by the main source view page.
+export function* watchLoadRepoStatus() {
+ yield takeLatest(String(loadRepoSuccess), handleRepoStatus);
+}
+
+const REPO_STATUS_POLLING_FREQ_MS = 1000;
+
+function createRepoStatusPollingHandler(
+ parseRepoUri: (_: Action) => RepositoryUri,
+ handleStatus: any,
+ pollingActionFunction: any
+) {
+ return function*(a: Action) {
+ yield call(delay, REPO_STATUS_POLLING_FREQ_MS);
+
+ const repoUri = parseRepoUri(a);
+ let keepPolling = false;
+ try {
+ const repoStatus = yield call(fetchStatus, repoUri);
+ keepPolling = yield handleStatus(repoStatus, repoUri);
+ } catch (err) {
+ // Fetch repository status error. Ignore and keep trying.
+ keepPolling = true;
+ }
+
+ if (keepPolling) {
+ yield put(pollingActionFunction(repoUri));
+ }
+ };
+}
+
+const handleRepoCloneStatusPolling = createRepoStatusPollingHandler(
+ (action: Action) => {
+ if (action.type === String(importRepo)) {
+ const repoUrl: string = action.payload;
+ return RepositoryUtils.buildRepository(repoUrl).uri;
+ } else if (action.type === String(pollRepoCloneStatus)) {
+ return action.payload;
+ }
+ },
+ function*(status: any, repoUri: RepositoryUri) {
+ if (
+ // Repository has been deleted during the clone
+ (!status.gitStatus && !status.indexStatus && !status.deleteStatus) ||
+ // Repository is in delete during the clone
+ status.deleteStatus
+ ) {
+ // Stop polling git progress
+ return false;
+ }
+
+ if (status.gitStatus) {
+ const { progress, cloneProgress, errorMessage, timestamp } = status.gitStatus;
+ yield put(
+ updateCloneProgress({
+ progress,
+ timestamp: moment(timestamp).toDate(),
+ repoUri,
+ errorMessage,
+ cloneProgress,
+ })
+ );
+ // Keep polling if the progress is not 100% yet.
+ return isInProgress(progress);
+ } else {
+ // Keep polling if the indexStatus has not been persisted yet.
+ return true;
+ }
+ },
+ pollRepoCloneStatus
+);
+
+export function* watchRepoCloneStatusPolling() {
+ // The repository clone status polling will be triggered by:
+ // * user click import repository
+ // * repeating pollRepoCloneStatus action by the poller itself.
+ yield takeEvery([String(importRepo), String(pollRepoCloneStatus)], handleRepoCloneStatusPolling);
+}
+
+const handleRepoIndexStatusPolling = createRepoStatusPollingHandler(
+ (action: Action) => {
+ if (action.type === String(indexRepo) || action.type === String(pollRepoIndexStatus)) {
+ return action.payload;
+ } else if (action.type === String(updateCloneProgress)) {
+ return action.payload.repoUri;
+ }
+ },
+ function*(status: any, repoUri: RepositoryUri) {
+ if (
+ // Repository has been deleted during the index
+ (!status.gitStatus && !status.indexStatus && !status.deleteStatus) ||
+ // Repository is in delete during the index
+ status.deleteStatus
+ ) {
+ // Stop polling index progress
+ return false;
+ }
+
+ if (status.indexStatus) {
+ yield put(
+ updateIndexProgress({
+ progress: status.indexStatus.progress,
+ timestamp: moment(status.indexStatus.timestamp).toDate(),
+ repoUri,
+ })
+ );
+ // Keep polling if the progress is not 100% yet.
+ return isInProgress(status.indexStatus.progress);
+ } else {
+ // Keep polling if the indexStatus has not been persisted yet.
+ return true;
+ }
+ },
+ pollRepoIndexStatus
+);
+
+export function* watchRepoIndexStatusPolling() {
+ // The repository index status polling will be triggered by:
+ // * user click index repository
+ // * clone is done
+ // * repeating pollRepoIndexStatus action by the poller itself.
+ yield takeEvery(
+ [String(indexRepo), cloneCompletedPattern, String(pollRepoIndexStatus)],
+ handleRepoIndexStatusPolling
+ );
+}
+
+const handleRepoDeleteStatusPolling = createRepoStatusPollingHandler(
+ (action: Action) => {
+ return action.payload;
+ },
+ function*(status: any, repoUri: RepositoryUri) {
+ if (!status.gitStatus && !status.indexStatus && !status.deleteStatus) {
+ // If all the statuses cannot be found, this indicates the the repository has been successfully
+ // removed.
+ yield put(
+ updateDeleteProgress({
+ progress: WorkerReservedProgress.COMPLETED,
+ repoUri,
+ })
+ );
+ return false;
+ }
+
+ if (status.deleteStatus) {
+ yield put(
+ updateDeleteProgress({
+ progress: status.deleteStatus.progress,
+ timestamp: moment(status.deleteStatus.timestamp).toDate(),
+ repoUri,
+ })
+ );
+ return isInProgress(status.deleteStatus.progress);
+ }
+ },
+ pollRepoDeleteStatus
+);
+
+export function* watchRepoDeleteStatusPolling() {
+ // The repository delete status polling will be triggered by:
+ // * user click delete repository
+ // * repeating pollRepoDeleteStatus action by the poller itself.
+ yield takeEvery(
+ [String(deleteRepo), String(pollRepoDeleteStatus)],
+ handleRepoDeleteStatusPolling
+ );
+}
diff --git a/x-pack/plugins/code/public/sagas/repository.ts b/x-pack/plugins/code/public/sagas/repository.ts
new file mode 100644
index 0000000000000..25a6797368380
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/repository.ts
@@ -0,0 +1,173 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { kfetch } from 'ui/kfetch';
+
+import { Action } from 'redux-actions';
+import { call, put, takeEvery, takeLatest } from 'redux-saga/effects';
+import {
+ deleteRepo,
+ deleteRepoFailed,
+ deleteRepoSuccess,
+ fetchRepoConfigFailed,
+ fetchRepoConfigs,
+ fetchRepoConfigSuccess,
+ fetchRepos,
+ fetchReposFailed,
+ fetchReposSuccess,
+ gotoRepo,
+ importRepo,
+ importRepoFailed,
+ importRepoSuccess,
+ indexRepo,
+ indexRepoFailed,
+ indexRepoSuccess,
+ initRepoCommand,
+ updateDeleteProgress,
+ updateIndexProgress,
+} from '../actions';
+import { loadLanguageServers } from '../actions/language_server';
+import { history } from '../utils/url';
+import { adminRoutePattern } from './patterns';
+
+function requestRepos(): any {
+ return kfetch({ pathname: '/api/code/repos' });
+}
+
+function* handleFetchRepos() {
+ try {
+ const repos = yield call(requestRepos);
+ yield put(fetchReposSuccess(repos));
+ } catch (err) {
+ yield put(fetchReposFailed(err));
+ }
+}
+
+function requestDeleteRepo(uri: string) {
+ return kfetch({ pathname: `/api/code/repo/${uri}`, method: 'delete' });
+}
+
+function requestIndexRepo(uri: string) {
+ return kfetch({ pathname: `/api/code/repo/index/${uri}`, method: 'post' });
+}
+
+function* handleDeleteRepo(action: Action) {
+ try {
+ yield call(requestDeleteRepo, action.payload || '');
+ yield put(deleteRepoSuccess(action.payload || ''));
+ yield put(
+ updateDeleteProgress({
+ repoUri: action.payload as string,
+ progress: 0,
+ })
+ );
+ } catch (err) {
+ yield put(deleteRepoFailed(err));
+ }
+}
+
+function* handleIndexRepo(action: Action) {
+ try {
+ yield call(requestIndexRepo, action.payload || '');
+ yield put(indexRepoSuccess(action.payload || ''));
+ yield put(
+ updateIndexProgress({
+ repoUri: action.payload as string,
+ progress: 0,
+ })
+ );
+ } catch (err) {
+ yield put(indexRepoFailed(err));
+ }
+}
+
+function requestImportRepo(uri: string) {
+ return kfetch({
+ pathname: '/api/code/repo',
+ method: 'post',
+ body: JSON.stringify({ url: uri }),
+ });
+}
+
+function* handleImportRepo(action: Action) {
+ try {
+ const data = yield call(requestImportRepo, action.payload || '');
+ yield put(importRepoSuccess(data));
+ } catch (err) {
+ yield put(importRepoFailed(err));
+ }
+}
+function* handleFetchRepoConfigs() {
+ try {
+ const configs = yield call(requestRepoConfigs);
+ yield put(fetchRepoConfigSuccess(configs));
+ } catch (e) {
+ yield put(fetchRepoConfigFailed(e));
+ }
+}
+
+function requestRepoConfigs() {
+ return kfetch({ pathname: '/api/code/workspace', method: 'get' });
+}
+
+function* handleInitCmd(action: Action) {
+ const repoUri = action.payload as string;
+ yield call(requestRepoInitCmd, repoUri);
+}
+
+function requestRepoInitCmd(repoUri: string) {
+ return kfetch({
+ pathname: `/api/code/workspace/${repoUri}/master`,
+ query: { force: true },
+ method: 'post',
+ });
+}
+function* handleGotoRepo(action: Action) {
+ const repoUri = action.payload as string;
+ const repo = yield call(requestRepo, repoUri);
+ history.replace(`${repoUri}/tree/${repo.defaultBranch || 'master'}`);
+}
+
+function requestRepo(uri: string) {
+ return kfetch({ pathname: `/api/code/repo${uri}`, method: 'get' });
+}
+
+export function* watchImportRepo() {
+ yield takeEvery(String(importRepo), handleImportRepo);
+}
+
+export function* watchDeleteRepo() {
+ yield takeEvery(String(deleteRepo), handleDeleteRepo);
+}
+
+export function* watchIndexRepo() {
+ yield takeEvery(String(indexRepo), handleIndexRepo);
+}
+
+export function* watchFetchRepos() {
+ yield takeEvery(String(fetchRepos), handleFetchRepos);
+}
+
+export function* watchFetchRepoConfigs() {
+ yield takeEvery(String(fetchRepoConfigs), handleFetchRepoConfigs);
+}
+
+export function* watchInitRepoCmd() {
+ yield takeEvery(String(initRepoCommand), handleInitCmd);
+}
+
+export function* watchGotoRepo() {
+ yield takeLatest(String(gotoRepo), handleGotoRepo);
+}
+
+function* handleAdminRouteChange() {
+ yield put(fetchRepos());
+ yield put(fetchRepoConfigs());
+ yield put(loadLanguageServers());
+}
+
+export function* watchAdminRouteChange() {
+ yield takeLatest(adminRoutePattern, handleAdminRouteChange);
+}
diff --git a/x-pack/plugins/code/public/sagas/search.ts b/x-pack/plugins/code/public/sagas/search.ts
new file mode 100644
index 0000000000000..f378ea0c869db
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/search.ts
@@ -0,0 +1,141 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import queryString from 'querystring';
+import { kfetch } from 'ui/kfetch';
+
+import { Action } from 'redux-actions';
+import { call, put, takeEvery, takeLatest } from 'redux-saga/effects';
+
+import { SearchScope } from '../../model';
+import {
+ changeSearchScope,
+ documentSearch,
+ documentSearchFailed,
+ DocumentSearchPayload,
+ documentSearchSuccess,
+ Match,
+ repositorySearch,
+ repositorySearchFailed,
+ RepositorySearchPayload,
+ repositorySearchQueryChanged,
+ repositorySearchSuccess,
+ searchReposForScope,
+ searchReposForScopeFailed,
+ searchReposForScopeSuccess,
+} from '../actions';
+import { searchRoutePattern } from './patterns';
+
+function requestDocumentSearch(payload: DocumentSearchPayload) {
+ const { query, page, languages, repositories, repoScope } = payload;
+ const queryParams: { [key: string]: string | number | boolean } = {
+ q: query,
+ };
+
+ if (page) {
+ queryParams.p = page;
+ }
+
+ if (languages) {
+ queryParams.langs = languages;
+ }
+
+ if (repositories) {
+ queryParams.repos = repositories;
+ }
+
+ if (repoScope) {
+ queryParams.repoScope = repoScope;
+ }
+
+ if (query && query.length > 0) {
+ return kfetch({
+ pathname: `/api/code/search/doc`,
+ method: 'get',
+ query: queryParams,
+ });
+ } else {
+ return {
+ documents: [],
+ took: 0,
+ total: 0,
+ };
+ }
+}
+
+function* handleDocumentSearch(action: Action) {
+ try {
+ const data = yield call(requestDocumentSearch, action.payload!);
+ yield put(documentSearchSuccess(data));
+ } catch (err) {
+ yield put(documentSearchFailed(err));
+ }
+}
+
+function requestRepositorySearch(q: string) {
+ return kfetch({
+ pathname: `/api/code/search/repo`,
+ method: 'get',
+ query: { q },
+ });
+}
+
+export function* watchDocumentSearch() {
+ yield takeLatest(String(documentSearch), handleDocumentSearch);
+}
+
+function* handleRepositorySearch(action: Action) {
+ try {
+ const data = yield call(requestRepositorySearch, action.payload!.query);
+ yield put(repositorySearchSuccess(data));
+ } catch (err) {
+ yield put(repositorySearchFailed(err));
+ }
+}
+
+export function* watchRepositorySearch() {
+ yield takeLatest(
+ [String(repositorySearch), String(repositorySearchQueryChanged)],
+ handleRepositorySearch
+ );
+}
+
+function* handleSearchRouteChange(action: Action) {
+ const { location } = action.payload!;
+ const rawSearchStr = location.search.length > 0 ? location.search.substring(1) : '';
+ const queryParams = queryString.parse(rawSearchStr);
+ const { q, p, langs, repos, scope, repoScope } = queryParams;
+ yield put(changeSearchScope(scope as SearchScope));
+ if (scope === SearchScope.REPOSITORY) {
+ yield put(repositorySearch({ query: q as string }));
+ } else {
+ yield put(
+ documentSearch({
+ query: q as string,
+ page: p as string,
+ languages: langs as string,
+ repositories: repos as string,
+ repoScope: repoScope as string,
+ })
+ );
+ }
+}
+
+export function* watchSearchRouteChange() {
+ yield takeLatest(searchRoutePattern, handleSearchRouteChange);
+}
+
+function* handleReposSearchForScope(action: Action) {
+ try {
+ const data = yield call(requestRepositorySearch, action.payload!.query);
+ yield put(searchReposForScopeSuccess(data));
+ } catch (err) {
+ yield put(searchReposForScopeFailed(err));
+ }
+}
+
+export function* watchRepoScopeSearch() {
+ yield takeEvery(searchReposForScope, handleReposSearchForScope);
+}
diff --git a/x-pack/plugins/code/public/sagas/setup.ts b/x-pack/plugins/code/public/sagas/setup.ts
new file mode 100644
index 0000000000000..62f8cdb6cadbc
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/setup.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { kfetch } from 'ui/kfetch';
+import { call, put, takeEvery } from 'redux-saga/effects';
+import { checkSetupFailed, checkSetupSuccess } from '../actions';
+import { rootRoutePattern, setupRoutePattern } from './patterns';
+
+function* handleRootRoute() {
+ try {
+ yield call(requestSetup);
+ yield put(checkSetupSuccess());
+ } catch (e) {
+ yield put(checkSetupFailed());
+ }
+}
+
+function requestSetup() {
+ return kfetch({ pathname: `/api/code/setup`, method: 'head' });
+}
+
+export function* watchRootRoute() {
+ yield takeEvery(rootRoutePattern, handleRootRoute);
+ yield takeEvery(setupRoutePattern, handleRootRoute);
+}
diff --git a/x-pack/plugins/code/public/sagas/status.ts b/x-pack/plugins/code/public/sagas/status.ts
new file mode 100644
index 0000000000000..c418d8384ef5e
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/status.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Action } from 'redux-actions';
+import { put, select, takeEvery } from 'redux-saga/effects';
+import { WorkerReservedProgress } from '../../model';
+import {
+ deleteRepoFinished,
+ Match,
+ routeChange,
+ updateCloneProgress,
+ updateDeleteProgress,
+} from '../actions';
+import * as ROUTES from '../components/routes';
+import { RootState } from '../reducers';
+
+const matchSelector = (state: RootState) => state.route.match;
+
+export const cloneCompletedPattern = (action: Action) =>
+ action.type === String(updateCloneProgress) &&
+ action.payload.progress === WorkerReservedProgress.COMPLETED;
+
+const deleteCompletedPattern = (action: Action) =>
+ action.type === String(updateDeleteProgress) &&
+ action.payload.progress === WorkerReservedProgress.COMPLETED;
+
+function* handleRepoCloneSuccess() {
+ const match: Match = yield select(matchSelector);
+ if (match.path === ROUTES.MAIN || match.path === ROUTES.MAIN_ROOT) {
+ yield put(routeChange(match));
+ }
+}
+
+export function* watchRepoCloneSuccess() {
+ yield takeEvery(cloneCompletedPattern, handleRepoCloneSuccess);
+}
+
+function* handleRepoDeleteFinished(action: any) {
+ yield put(deleteRepoFinished(action.payload.repoUri));
+}
+
+export function* watchRepoDeleteFinished() {
+ yield takeEvery(deleteCompletedPattern, handleRepoDeleteFinished);
+}
diff --git a/x-pack/plugins/code/public/sagas/structure.ts b/x-pack/plugins/code/public/sagas/structure.ts
new file mode 100644
index 0000000000000..f574cf6ab9175
--- /dev/null
+++ b/x-pack/plugins/code/public/sagas/structure.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { Action } from 'redux-actions';
+import { call, put, takeEvery } from 'redux-saga/effects';
+import { LspRestClient, TextDocumentMethods } from '../../common/lsp_client';
+import { loadStructure, loadStructureFailed, loadStructureSuccess } from '../actions';
+
+function requestStructure(uri?: string) {
+ const lspClient = new LspRestClient('/api/code/lsp');
+ const lspMethods = new TextDocumentMethods(lspClient);
+ return lspMethods.documentSymbol.send({
+ textDocument: {
+ uri: uri || '',
+ },
+ });
+}
+
+function* handleLoadStructure(action: Action) {
+ try {
+ const data = yield call(requestStructure, `git:/${action.payload}`);
+ yield put(loadStructureSuccess({ path: action.payload!, data }));
+ } catch (err) {
+ yield put(loadStructureFailed(err));
+ }
+}
+
+export function* watchLoadStructure() {
+ yield takeEvery(String(loadStructure), handleLoadStructure);
+}
diff --git a/x-pack/plugins/code/public/selectors/index.ts b/x-pack/plugins/code/public/selectors/index.ts
new file mode 100644
index 0000000000000..036b899a93c15
--- /dev/null
+++ b/x-pack/plugins/code/public/selectors/index.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { FileTree, RepositoryUri } from '../../model';
+import { RootState } from '../reducers';
+
+export const getTree = (state: RootState) => state.file.tree;
+
+export const lastRequestPathSelector: (state: RootState) => string = (state: RootState) =>
+ state.symbol.lastRequestPath || '';
+
+export const structureSelector = (state: RootState) => {
+ const pathname = lastRequestPathSelector(state);
+ const symbols = state.symbol.structureTree[pathname];
+ return symbols || [];
+};
+
+export const refUrlSelector = (state: RootState) => {
+ const payload = state.editor.refPayload;
+ if (payload) {
+ const { line, character } = payload.position;
+ return `${payload.textDocument.uri}!L${line}:${character}`;
+ }
+ return undefined;
+};
+
+export const fileSelector = (state: RootState) => state.file.file;
+
+export const searchScopeSelector = (state: RootState) => state.search.scope;
+
+export const repoUriSelector = (state: RootState) => {
+ const { resource, org, repo } = state.route.match.params;
+ return `${resource}/${org}/${repo}`;
+};
+
+export const statusSelector = (state: RootState, repoUri: RepositoryUri) => {
+ return state.status.status[repoUri];
+};
+
+export const treeCommitsSelector = (state: RootState) => {
+ const path = state.file.currentPath;
+ if (path === '') {
+ return state.file.commits;
+ } else {
+ return state.file.treeCommits[path];
+ }
+};
+
+export const hasMoreCommitsSelector = (state: RootState) => {
+ const path = state.file.currentPath;
+ const isLoading = state.file.loadingCommits;
+ if (isLoading) {
+ return false;
+ }
+ if (state.file.commitsFullyLoaded[path]) {
+ return false;
+ }
+ const commits = path === '' ? state.file.commits : state.file.treeCommits[path];
+ if (!commits) {
+ // To avoid infinite loops in component `InfiniteScroll`,
+ // here we set hasMore to false before we receive the first batch.
+ return false;
+ }
+ return true;
+};
+
+function find(tree: FileTree, paths: string[]): FileTree | null {
+ if (paths.length === 0) {
+ return tree;
+ }
+ const [p, ...rest] = paths;
+ if (tree.children) {
+ const child = tree.children.find((c: FileTree) => c.name === p);
+ if (child) {
+ return find(child, rest);
+ }
+ }
+ return null;
+}
+
+export const currentTreeSelector = (state: RootState) => {
+ const tree = getTree(state);
+ const path = state.file.currentPath;
+ return find(tree, path.split('/'));
+};
+
+export const currentRepoSelector = (state: RootState) => state.repository.currentRepository;
+
+export const repoScopeSelector = (state: RootState) => state.search.searchOptions.repoScope;
diff --git a/x-pack/plugins/code/public/stores/index.ts b/x-pack/plugins/code/public/stores/index.ts
new file mode 100644
index 0000000000000..b502ba97aa676
--- /dev/null
+++ b/x-pack/plugins/code/public/stores/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { applyMiddleware, compose, createStore } from 'redux';
+import createSagaMiddleware from 'redux-saga';
+
+import { rootReducer } from '../reducers';
+import { rootSaga } from '../sagas';
+
+const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+
+const sagaMW = createSagaMiddleware();
+
+export const store = createStore(rootReducer, composeEnhancers(applyMiddleware(sagaMW)));
+
+sagaMW.run(rootSaga);
diff --git a/x-pack/plugins/code/public/style/_buttons.scss b/x-pack/plugins/code/public/style/_buttons.scss
new file mode 100644
index 0000000000000..1f9dc1ff84e73
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_buttons.scss
@@ -0,0 +1,27 @@
+.codeButton__project {
+ border-radius: $euiBorderRadius;
+ height: 4rem;
+ padding: $euiSizeS;
+ width: 4rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ transition: background-color $euiAnimSpeedFast $euiAnimSlightResistance;
+ padding: $euiSizeM 0;
+ &:hover {
+ background-color: $euiColorLightestShade;
+ }
+}
+
+.codeButton__projectImport {
+ margin-top: $euiSizeL;
+}
+
+.codeButtonGroup {
+ margin-left: $euiSizeS;
+ .euiButton {
+ font-size: $euiFontSizeS;
+ }
+}
diff --git a/x-pack/plugins/code/public/style/_filetree.scss b/x-pack/plugins/code/public/style/_filetree.scss
new file mode 100644
index 0000000000000..66827dc89b732
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_filetree.scss
@@ -0,0 +1,57 @@
+%extendCodeNode__item {
+ padding: $euiSizeXS $euiSizeXS $euiSizeXS $euiSize;
+ cursor: pointer;
+ white-space: nowrap;
+ margin-left: $euiSizeS;
+ z-index: 2;
+}
+
+.codeFileTree__container .euiSideNavItem--branch {
+ padding-left: $euiSize;
+}
+
+.codeFileTree__node {
+ display: flex;
+ flex-direction: row;
+ position: relative;
+}
+
+.codeFileTree__item {
+ @extend %extendCodeNode__item;
+}
+
+.codeFileTree__file {
+ margin-left: calc(28rem/16);
+}
+
+.codeFileTree__directory {
+ margin-left: $euiSizeS;
+ vertical-align: middle;
+}
+
+.codeFileTree__node--fullWidth {
+ position: absolute;
+ width: 100%;
+ background: $euiColorLightShade;
+ left: 0;
+ height: 1.8125rem;
+ // Use box shadows instead of tricky absolute positioning to set the active state
+ box-shadow: -10rem 0 0 $euiColorLightShade, 10rem 0 0 $euiColorLightShade;
+}
+
+.codeFileTree__container {
+ .euiSideNavItem__items {
+ margin: 0;
+ }
+}
+
+@include euiBreakpoint('xs','s') {
+ .codeFileTree__container {
+ margin-left: -$euiSizeXL;
+ margin-top: -$euiSizeL;
+
+ .euiSideNav__mobileToggle {
+ display: none;
+ }
+ }
+}
diff --git a/x-pack/plugins/code/public/style/_filters.scss b/x-pack/plugins/code/public/style/_filters.scss
new file mode 100644
index 0000000000000..e360ec75c86ed
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_filters.scss
@@ -0,0 +1,8 @@
+.codeFilter__group {
+ padding: 0 1rem;
+ margin-top: $euiSizeS;
+}
+
+.codeFilter__title {
+ height: 2.25rem;
+}
diff --git a/x-pack/plugins/code/public/style/_layout.scss b/x-pack/plugins/code/public/style/_layout.scss
new file mode 100644
index 0000000000000..c2d157c02d95d
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_layout.scss
@@ -0,0 +1,179 @@
+.euiFlexItem.codeContainer__adminWrapper {
+ padding: $euiSize $euiSizeXL;
+ flex-grow: 1;
+}
+
+.codeContainer__root {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.codeContainer__rootInner {
+ display: flex;
+ flex-direction: row;
+ flex-grow: 1;
+ height: 100%;
+}
+
+.codeContainer__main {
+ width: calc(100% - 16rem);
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.codeContainer__import {
+ max-width: 56rem;
+ margin: auto;
+}
+
+.codeContainer__setup {
+ width: 56rem;
+ margin: 0 auto;
+ padding: 0 0 $euiSizeXXL 0;
+
+ .codeContainer__setup--step {
+ margin-top: $euiSizeXXL;
+ width: 100%;
+ }
+}
+
+.codeContainer__editor {
+ overflow: hidden;
+ flex-grow: 1;
+}
+
+.codeContainer__blame {
+ position: relative;
+ max-height: calc(100% - (6rem + 1px));
+ flex-grow: 1;
+}
+
+.codeContainer__directoryView, .codeContainer__history {
+ flex-grow: 1;
+ overflow: auto;
+}
+
+.codeContainer__searchBar {
+ width: 100%;
+}
+
+.codeContainer__select {
+ margin-right: $euiSizeS;
+}
+
+.codeContainer__tabs {
+ height: 3.5rem;
+}
+
+.codeContainer__search--inner {
+ overflow-y: scroll;
+ padding: 1rem;
+}
+
+.codeContainer__search {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+}
+
+.codeContainer__symbolTree {
+ padding: $euiSizeL $euiSizeM;
+ position: relative;
+ display: inline-block;
+ min-width: 100%;
+ height: 100%;
+}
+
+.codeContainer__search--results {
+ margin-top: $euiSize;
+}
+
+.codeSidebar__container {
+ background-color: $euiColorLightestShade;
+ border-right: solid 1px $euiBorderColor;
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow: auto;
+}
+
+.codeSearchbar__container {
+ height: 3.5rem;
+ padding: $euiSizeS;
+ border-bottom: $euiBorderWidthThin solid $euiBorderColor;
+}
+
+.codeSearchSettings__flyout {
+ max-width: 28rem;
+}
+
+.codeFooter--error {
+ color: $euiColorDanger;
+}
+
+.codePanel__project {
+ position: relative;
+ margin-bottom: $euiSizeS;
+}
+
+.codePanel__project--error {
+ border: 2px solid $euiColorDanger;;
+}
+
+.codeSettingsPanel__icon {
+ display: inline-block;
+ position: relative;
+ top: $euiSizeS;
+}
+
+.codeText__blameMessage {
+ max-width: 10rem;
+}
+
+.codeAvatar {
+ margin: auto $euiSizeS auto 0;
+}
+
+.codeContainer__progress {
+ width: 40rem;
+ padding: $euiSizeXS;
+ border: $euiBorderThin;
+}
+
+.codeContainer__commitMessages {
+ overflow: auto;
+ flex: 1;
+ padding: $euiSizeM;
+
+ .codeHeader__commit {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+}
+
+.codeContainer__directoryList {
+ padding: $euiSizeL;
+ &:first-child{
+ padding-bottom: 0;
+ }
+ &:not(:first-child) {
+ padding-top: 0;
+ }
+}
+
+.codePanel__error {
+ width: 31rem;
+ margin: auto;
+}
+
+.codeLoader {
+ padding: 1.5rem;
+ text-align: center;
+}
diff --git a/x-pack/plugins/code/public/style/_markdown.scss b/x-pack/plugins/code/public/style/_markdown.scss
new file mode 100644
index 0000000000000..01ceb042f0348
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_markdown.scss
@@ -0,0 +1,11 @@
+.markdown-body {
+ color: $euiColorDarkestShade;
+ a, a:visited {
+ color: $euiColorPrimary;
+ text-decoration: underline;
+ }
+}
+
+.markdown-body .highlight pre, .markdown-body pre {
+ background-color: $euiColorLightestShade;;
+}
diff --git a/x-pack/plugins/code/public/style/_monaco.scss b/x-pack/plugins/code/public/style/_monaco.scss
new file mode 100644
index 0000000000000..f2e6eb8d1d558
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_monaco.scss
@@ -0,0 +1,109 @@
+.codeSearch__highlight {
+ background-color: $euiColorVis5;
+ color: black !important;
+ padding: $euiSizeXS / 2;
+ border-radius: $euiSizeXS / 2;
+ font-weight: bold;
+ font-style: oblique;
+}
+
+
+.code-monaco-highlight-line {
+ background: $euiCodeBlockBuiltInColor;
+}
+
+.code-mark-line-number {
+ background: $euiCodeBlockBuiltInColor;
+ width: $euiSizeXS;
+}
+
+.monaco-editor .margin-view-overlays .line-numbers {
+ text-align: center;
+ border-right: $euiBorderThin;
+}
+
+.monaco-editor-hover {
+ min-width: 350px;
+ border: $euiBorderThin;
+ border-bottom: 0;
+ cursor: default;
+ position: absolute;
+ overflow-y: auto;
+ z-index: 5;
+ -webkit-user-select: text;
+ -ms-user-select: text;
+ -moz-user-select: text;
+ -o-user-select: text;
+ user-select: text;
+ box-sizing: initial;
+ animation: fadein 0.1s linear;
+ line-height: 1.5em;
+ background: $euiColorLightestShade;
+ border-radius: 4px 4px 4px 4px;
+ @include euiBottomShadow;
+}
+
+.monaco-editor-hover .hover-row {
+ padding: 4px 5px;
+}
+
+.monaco-editor-hover .button-group {
+ background: linear-gradient(-180deg, $euiColorLightestShade 0%, $euiColorEmptyShade 100%);
+ border-radius: 0 0 4px 4px;
+ box-shadow: 0 -1px 0 0 $euiBorderColor;
+ height: 33px;
+}
+
+.monaco-editor-hover .button-group button:not(:first-child) {
+ border-left: 1px solid $euiBorderColor;
+}
+
+.monaco-editor-hover .button-group button {
+ font-size: 13px;
+ font-weight: normal;
+ border: 0;
+ border-radius: 0;
+ flex: 1;
+}
+
+.monaco-editor .scroll-decoration {
+ display: none;
+}
+
+.code-mark-line-number,
+.code-monaco-highlight-line {
+ background-color: $euiColorLightShade;
+}
+
+.text-placeholder {
+ width: 100%;
+ height: 18px;
+ margin: 4px 0 4px 0;
+}
+
+.gradient {
+ animation-duration: 1.8s;
+ animation-fill-mode: forwards;
+ animation-iteration-count: infinite;
+ animation-name: placeHolderShimmer;
+ animation-timing-function: linear;
+ background: $euiColorLightestShade;
+ background: linear-gradient(
+ to right,
+ $euiColorLightShade 8%,
+ $euiColorLightestShade 38%,
+ $euiColorLightShade 54%
+ );
+ background-size: 1000px 640px;
+
+ position: relative;
+}
+
+@keyframes placeHolderShimmer {
+ 0% {
+ background-position: -468px 0;
+ }
+ 100% {
+ background-position: 468px 0;
+ }
+}
diff --git a/x-pack/plugins/code/public/style/_query_bar.scss b/x-pack/plugins/code/public/style/_query_bar.scss
new file mode 100644
index 0000000000000..9f00d96b10b72
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_query_bar.scss
@@ -0,0 +1,6 @@
+.codeQueryBar {
+ max-width: 90%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
diff --git a/x-pack/plugins/code/public/style/_shortcuts.scss b/x-pack/plugins/code/public/style/_shortcuts.scss
new file mode 100644
index 0000000000000..2cf519b7f2562
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_shortcuts.scss
@@ -0,0 +1,23 @@
+.codeShortcuts__key {
+ background: $euiColorEmptyShade;
+ border: $euiBorderThin;
+ box-sizing: border-box;
+ box-shadow: 0px 2px 0px $euiColorLightestShade;
+ border-radius: $euiSizeXS;
+ width: $euiSizeL;
+ min-width: $euiSizeL;
+ height: $euiSizeL;
+ display: inline-block;
+ text-align: center;
+ margin: $euiSizeXS;
+ line-height: $euiSizeL;
+ text-transform: uppercase;
+ font-size: $euiFontSizeS;
+}
+
+.codeShortcuts__helpText {
+ line-height: $euiSizeL;
+ font-size: $euiFontSizeS;
+ margin-left: $euiSizeL / 2;
+ color: $euiColorDarkestShade;
+}
diff --git a/x-pack/plugins/code/public/style/_sidebar.scss b/x-pack/plugins/code/public/style/_sidebar.scss
new file mode 100644
index 0000000000000..9986232686bad
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_sidebar.scss
@@ -0,0 +1,95 @@
+.codeSidebar {
+ box-shadow: inset -1px 0 0 $euiColorLightShade;
+ padding: $euiSizeS;
+ flex-basis: 18rem;
+ flex-grow: 0;
+
+ .codeSidebar__heading {
+ margin: $euiSizeM $euiSizeM 0 $euiSizeM;
+ }
+
+ .code-sidebar__link {
+ background-color: transparent;
+ border-radius: $euiBorderRadius;
+ box-sizing: border-box;
+ padding-right: $euiSizeS;
+ width: 100%;
+ transition: all $euiAnimSpeedFast $euiAnimSlightResistance;
+ &:hover {
+ background-color: $euiColorLightestShade;
+ }
+ .euiFlexGroup--gutterLarge {
+ margin: 0;
+ }
+ }
+}
+
+.codeTab__projects {
+ .codeTab__projects--emptyHeader {
+ text-align: center;
+ }
+}
+
+.codeSymbol {
+ cursor: pointer;
+ display: flex;
+ flex-grow: 0;
+ flex-shrink: 0;
+ align-items: center;
+ height: 1.5rem;
+ margin-left: .75rem;
+
+ .euiToken {
+ margin-right: $euiSizeS;
+ }
+
+ > .euiIcon {
+ margin-right: $euiSizeXS;
+ }
+}
+
+.codeSymbol--nested {
+ margin-left: 2rem;
+}
+
+.code-structure-node {
+ padding: $euiSizeXS;
+}
+
+.code-full-width-node {
+ position: absolute;
+ width: 100%;
+ background: $euiColorLightShade;
+ left: 0;
+ height: 1.5rem;
+}
+
+.euiSideNavItem__items {
+ position: static;
+}
+
+.codeStructureTree--icon {
+ margin: auto 0.25rem;
+}
+
+.code-symbol-link {
+ &:focus {
+ animation: none !important;
+ }
+}
+
+
+
+// EUI Overrides
+// TODO: Add 'code' prefixed classnames
+.euiSideNavItem--root + .euiSideNavItem--root {
+ margin-top: 1rem;
+}
+
+.euiSideNavItem__items:after {
+ width: 0;
+}
+
+.euiSideNavItem--trunk > .euiSideNavItem__items {
+ margin-left: $euiSize;
+}
diff --git a/x-pack/plugins/code/public/style/_utilities.scss b/x-pack/plugins/code/public/style/_utilities.scss
new file mode 100644
index 0000000000000..82c5e17bfdf18
--- /dev/null
+++ b/x-pack/plugins/code/public/style/_utilities.scss
@@ -0,0 +1,11 @@
+.codeUtility__cursor--pointer {
+ cursor: pointer;
+}
+
+.codeUtility__width--half {
+ width: 50%;
+}
+
+.codeMargin__title {
+ margin: $euiSizeXS 0 $euiSize $euiSizeM;
+}
diff --git a/x-pack/plugins/code/public/style/variables.ts b/x-pack/plugins/code/public/style/variables.ts
new file mode 100644
index 0000000000000..eeeba1000f575
--- /dev/null
+++ b/x-pack/plugins/code/public/style/variables.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const rem = 14;
+
+export function px(value: number): string {
+ return `${value}px`;
+}
+
+export function percent(value: number): string {
+ return `${value}%`;
+}
+
+export function pxToRem(value: number): string {
+ return `${value / rem}rem`;
+}
+
+export const colors = {
+ textBlue: '#0079A5',
+ borderGrey: '#D9D9D9',
+ white: '#fff',
+ textGrey: '#3F3F3F',
+};
+
+export const fontSizes = {
+ small: '10px',
+ normal: '1rem',
+ large: '18px',
+ xlarge: '2rem',
+};
+
+export const fontFamily = 'SFProText-Regular';
diff --git a/x-pack/plugins/code/public/utils/test_utils.ts b/x-pack/plugins/code/public/utils/test_utils.ts
new file mode 100644
index 0000000000000..e88dbafb61414
--- /dev/null
+++ b/x-pack/plugins/code/public/utils/test_utils.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Action, History, Location } from 'history';
+import { match } from 'react-router-dom';
+
+interface LocationParam {
+ pathname?: string;
+ search?: string;
+ hash?: string;
+ state?: string;
+}
+
+export function createLocation(location: LocationParam): Location {
+ return {
+ pathname: '',
+ search: '',
+ hash: '',
+ state: '',
+ ...location,
+ };
+}
+
+interface MatchParam {
+ path?: string;
+ url?: string;
+ isExact?: boolean;
+ params: Params;
+}
+
+export function createMatch(m: MatchParam): match {
+ return {
+ path: '',
+ url: '',
+ isExact: true,
+ ...m,
+ };
+}
+
+interface HParam {
+ length?: number;
+ action: Action;
+ location: Location;
+}
+
+export const mockFunction = jest.fn();
+
+export function createHistory(h: HParam): History {
+ return {
+ length: 0,
+ push: mockFunction,
+ replace: mockFunction,
+ go: mockFunction,
+ goBack: mockFunction,
+ goForward: mockFunction,
+ listen: () => mockFunction,
+ block: () => mockFunction,
+ createHref: mockFunction,
+ ...h,
+ };
+}
diff --git a/x-pack/plugins/code/public/utils/url.ts b/x-pack/plugins/code/public/utils/url.ts
new file mode 100644
index 0000000000000..0208434eeb5df
--- /dev/null
+++ b/x-pack/plugins/code/public/utils/url.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import createHistory from 'history/createHashHistory';
+
+export const history = createHistory();
+
+export const isImportRepositoryURLInvalid = (url: string) => url.trim() === '';
+
+export const decodeRevisionString = (revision: string) => {
+ return revision.replace(':', '/');
+};
+
+export const encodeRevisionString = (revision: string) => {
+ return revision.replace('/', ':');
+};
diff --git a/x-pack/plugins/code/server/__tests__/clone_worker.ts b/x-pack/plugins/code/server/__tests__/clone_worker.ts
new file mode 100644
index 0000000000000..f27281ab513cc
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/clone_worker.ts
@@ -0,0 +1,237 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import Git from '@elastic/nodegit';
+import assert from 'assert';
+import { delay } from 'bluebird';
+import fs from 'fs';
+import path from 'path';
+import rimraf from 'rimraf';
+import sinon from 'sinon';
+
+import { Repository } from '../../model';
+import { EsClient, Esqueue } from '../lib/esqueue';
+import { Logger } from '../log';
+import { CloneWorker } from '../queue';
+import { IndexWorker } from '../queue';
+import { RepositoryServiceFactory } from '../repository_service_factory';
+import { createTestServerOption, emptyAsyncFunc } from '../test_utils';
+import { ConsoleLoggerFactory } from '../utils/console_logger_factory';
+
+const log: Logger = new ConsoleLoggerFactory().getLogger(['test']);
+
+const esQueue = {};
+
+const serverOptions = createTestServerOption();
+
+function prepareProject(url: string, p: string) {
+ return new Promise(resolve => {
+ if (!fs.existsSync(p)) {
+ rimraf(p, error => {
+ Git.Clone.clone(url, p).then(repo => {
+ resolve(repo);
+ });
+ });
+ } else {
+ resolve();
+ }
+ });
+}
+
+function cleanWorkspace() {
+ return new Promise(resolve => {
+ rimraf(serverOptions.workspacePath, resolve);
+ });
+}
+
+describe('clone_worker_tests', () => {
+ // @ts-ignore
+ before(async () => {
+ return new Promise(resolve => {
+ rimraf(serverOptions.repoPath, resolve);
+ });
+ });
+
+ beforeEach(async function() {
+ // @ts-ignore
+ this.timeout(200000);
+ await prepareProject(
+ 'https://github.com/Microsoft/TypeScript-Node-Starter.git',
+ path.join(serverOptions.repoPath, 'github.com/Microsoft/TypeScript-Node-Starter')
+ );
+ });
+ // @ts-ignore
+ after(() => {
+ return cleanWorkspace();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('Execute clone job', async () => {
+ // Setup RepositoryService
+ const cloneSpy = sinon.spy();
+ const repoService = {
+ clone: emptyAsyncFunc,
+ };
+ repoService.clone = cloneSpy;
+ const repoServiceFactory = {
+ newInstance: (): void => {
+ return;
+ },
+ };
+ const newInstanceSpy = sinon.fake.returns(repoService);
+ repoServiceFactory.newInstance = newInstanceSpy;
+
+ const cloneWorker = new CloneWorker(
+ esQueue as Esqueue,
+ log,
+ {} as EsClient,
+ serverOptions,
+ {} as IndexWorker,
+ (repoServiceFactory as any) as RepositoryServiceFactory
+ );
+
+ await cloneWorker.executeJob({
+ payload: {
+ url: 'https://github.com/Microsoft/TypeScript-Node-Starter.git',
+ },
+ options: {},
+ timestamp: 0,
+ });
+
+ assert.ok(newInstanceSpy.calledOnce);
+ assert.ok(cloneSpy.calledOnce);
+ });
+
+ it('On clone job completed.', async () => {
+ // Setup IndexWorker
+ const enqueueJobSpy = sinon.spy();
+ const indexWorker = {
+ enqueueJob: emptyAsyncFunc,
+ };
+ indexWorker.enqueueJob = enqueueJobSpy;
+
+ // Setup EsClient
+ const updateSpy = sinon.spy();
+ const esClient = {
+ update: emptyAsyncFunc,
+ };
+ esClient.update = updateSpy;
+
+ const cloneWorker = new CloneWorker(
+ esQueue as Esqueue,
+ log,
+ esClient as EsClient,
+ serverOptions,
+ (indexWorker as any) as IndexWorker,
+ {} as RepositoryServiceFactory
+ );
+
+ await cloneWorker.onJobCompleted(
+ {
+ payload: {
+ url: 'https://github.com/Microsoft/TypeScript-Node-Starter.git',
+ },
+ options: {},
+ timestamp: 0,
+ },
+ {
+ uri: 'github.com/Microsoft/TypeScript-Node-Starter',
+ repo: ({
+ uri: 'github.com/Microsoft/TypeScript-Node-Starter',
+ } as any) as Repository,
+ }
+ );
+
+ // EsClient update got called twice. One for updating default branch and revision
+ // of a repository. The other for update git clone status.
+ assert.ok(updateSpy.calledTwice);
+
+ // Index request is issued after a 1s delay.
+ await delay(1000);
+ assert.ok(enqueueJobSpy.calledOnce);
+ });
+
+ it('On clone job enqueued.', async () => {
+ // Setup EsClient
+ const indexSpy = sinon.spy();
+ const esClient = {
+ index: emptyAsyncFunc,
+ };
+ esClient.index = indexSpy;
+
+ const cloneWorker = new CloneWorker(
+ esQueue as Esqueue,
+ log,
+ (esClient as any) as EsClient,
+ serverOptions,
+ {} as IndexWorker,
+ {} as RepositoryServiceFactory
+ );
+
+ await cloneWorker.onJobEnqueued({
+ payload: {
+ url: 'https://github.com/Microsoft/TypeScript-Node-Starter.git',
+ },
+ options: {},
+ timestamp: 0,
+ });
+
+ // Expect EsClient index to be called to update the progress to 0.
+ assert.ok(indexSpy.calledOnce);
+ });
+
+ it('Skip clone job for invalid git url', async () => {
+ // Setup RepositoryService
+ const cloneSpy = sinon.spy();
+ const repoService = {
+ clone: emptyAsyncFunc,
+ };
+ repoService.clone = cloneSpy;
+ const repoServiceFactory = {
+ newInstance: (): void => {
+ return;
+ },
+ };
+ const newInstanceSpy = sinon.fake.returns(repoService);
+ repoServiceFactory.newInstance = newInstanceSpy;
+
+ const cloneWorker = new CloneWorker(
+ esQueue as Esqueue,
+ log,
+ {} as EsClient,
+ serverOptions,
+ {} as IndexWorker,
+ (repoServiceFactory as any) as RepositoryServiceFactory
+ );
+
+ const result1 = await cloneWorker.executeJob({
+ payload: {
+ url: 'file:///foo/bar.git',
+ },
+ options: {},
+ timestamp: 0,
+ });
+
+ assert.ok(result1.repo === null);
+ assert.ok(newInstanceSpy.notCalled);
+ assert.ok(cloneSpy.notCalled);
+
+ const result2 = await cloneWorker.executeJob({
+ payload: {
+ url: '/foo/bar.git',
+ },
+ options: {},
+ timestamp: 0,
+ });
+
+ assert.ok(result2.repo === null);
+ assert.ok(newInstanceSpy.notCalled);
+ assert.ok(cloneSpy.notCalled);
+ });
+});
diff --git a/x-pack/plugins/code/server/__tests__/git_operations.ts b/x-pack/plugins/code/server/__tests__/git_operations.ts
new file mode 100644
index 0000000000000..0b87d97e04756
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/git_operations.ts
@@ -0,0 +1,135 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import Git from '@elastic/nodegit';
+import assert from 'assert';
+import { execSync } from 'child_process';
+import fs from 'fs';
+import * as mkdirp from 'mkdirp';
+import os from 'os';
+import path from 'path';
+import rimraf from 'rimraf';
+import { getDefaultBranch, GitOperations } from '../git_operations';
+import { createTestServerOption } from '../test_utils';
+
+describe('git_operations', () => {
+ it('get default branch from a non master repo', async () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test_git'));
+ // create a non-master using git commands
+ const shell = `
+ git init
+ git add 'run.sh'
+ git commit -m 'init commit'
+ git branch -m trunk
+ `;
+ fs.writeFileSync(path.join(tmpDir, 'run.sh'), shell, 'utf-8');
+ execSync('sh ./run.sh', {
+ cwd: tmpDir,
+ });
+
+ try {
+ const defaultBranch = await getDefaultBranch(tmpDir);
+ assert.strictEqual(defaultBranch, 'trunk');
+ } finally {
+ rimraf.sync(tmpDir);
+ }
+ return '';
+ });
+
+ async function prepareProject(repoPath: string) {
+ mkdirp.sync(repoPath);
+ const repo = await Git.Repository.init(repoPath, 0);
+ const content = '';
+ fs.writeFileSync(path.join(repo.workdir(), '1'), content, 'utf8');
+ const subFolder = 'src';
+ fs.mkdirSync(path.join(repo.workdir(), subFolder));
+ fs.writeFileSync(path.join(repo.workdir(), 'src/2'), content, 'utf8');
+ fs.writeFileSync(path.join(repo.workdir(), 'src/3'), content, 'utf8');
+
+ const index = await repo.refreshIndex();
+ await index.addByPath('1');
+ await index.addByPath('src/2');
+ await index.addByPath('src/3');
+ index.write();
+ const treeId = await index.writeTree();
+ const committer = Git.Signature.create('tester', 'test@test.com', Date.now() / 1000, 60);
+ const commit = await repo.createCommit(
+ 'HEAD',
+ committer,
+ committer,
+ 'commit for test',
+ treeId,
+ []
+ );
+ // eslint-disable-next-line no-console
+ console.log(`created commit ${commit.tostrS()}`);
+ return repo;
+ }
+
+ // @ts-ignore
+ before(async () => {
+ await prepareProject(path.join(serverOptions.repoPath, repoUri));
+ });
+ const repoUri = 'github.com/test/test_repo';
+
+ const serverOptions = createTestServerOption();
+
+ it('can iterate a repo', async () => {
+ const g = new GitOperations(serverOptions.repoPath);
+ let count = 0;
+ const iterator = await g.iterateRepo(repoUri, 'HEAD');
+ for await (const value of iterator) {
+ if (count === 0) {
+ assert.strictEqual('1', value.name);
+ assert.strictEqual('1', value.path);
+ } else if (count === 1) {
+ assert.strictEqual('2', value.name);
+ assert.strictEqual('src/2', value.path);
+ } else if (count === 2) {
+ assert.strictEqual('3', value.name);
+ assert.strictEqual('src/3', value.path);
+ } else {
+ assert.fail('this repo should contains exactly 2 files');
+ }
+ count++;
+ }
+ const totalFiles = await g.countRepoFiles(repoUri, 'HEAD');
+ assert.strictEqual(count, 3, 'this repo should contains exactly 2 files');
+ assert.strictEqual(totalFiles, 3, 'this repo should contains exactly 2 files');
+ });
+
+ it('get diff between arbitrary 2 revisions', async () => {
+ function cloneProject(url: string, p: string) {
+ return new Promise(resolve => {
+ if (!fs.existsSync(p)) {
+ rimraf(p, error => {
+ Git.Clone.clone(url, p).then(repo => {
+ resolve(repo);
+ });
+ });
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ await cloneProject(
+ 'https://github.com/Microsoft/TypeScript-Node-Starter.git',
+ path.join(serverOptions.repoPath, 'github.com/Microsoft/TypeScript-Node-Starter')
+ );
+
+ const g = new GitOperations(serverOptions.repoPath);
+ const d = await g.getDiff(
+ 'github.com/Microsoft/TypeScript-Node-Starter',
+ '6206f643',
+ '4779cb7e'
+ );
+ assert.equal(d.additions, 2);
+ assert.equal(d.deletions, 4);
+ assert.equal(d.files.length, 3);
+ // @ts-ignore
+ }).timeout(100000);
+});
diff --git a/x-pack/plugins/code/server/__tests__/lsp_incremental_indexer.ts b/x-pack/plugins/code/server/__tests__/lsp_incremental_indexer.ts
new file mode 100644
index 0000000000000..2bc81619c5e00
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/lsp_incremental_indexer.ts
@@ -0,0 +1,303 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import Git, { CloneOptions } from '@elastic/nodegit';
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import rimraf from 'rimraf';
+import sinon from 'sinon';
+
+import { DiffKind } from '../../common/git_diff';
+import { WorkerReservedProgress } from '../../model';
+import { LspIncrementalIndexer } from '../indexer/lsp_incremental_indexer';
+import { RepositoryGitStatusReservedField } from '../indexer/schema';
+import { EsClient } from '../lib/esqueue';
+import { Logger } from '../log';
+import { InstallManager } from '../lsp/install_manager';
+import { LspService } from '../lsp/lsp_service';
+import { RepositoryConfigController } from '../repository_config_controller';
+import { createTestServerOption, emptyAsyncFunc } from '../test_utils';
+import { ConsoleLoggerFactory } from '../utils/console_logger_factory';
+
+const log: Logger = new ConsoleLoggerFactory().getLogger(['test']);
+
+const esClient = {
+ bulk: emptyAsyncFunc,
+ get: emptyAsyncFunc,
+ deleteByQuery: emptyAsyncFunc,
+ indices: {
+ existsAlias: emptyAsyncFunc,
+ create: emptyAsyncFunc,
+ putAlias: emptyAsyncFunc,
+ },
+};
+
+function prepareProject(url: string, p: string) {
+ const opts: CloneOptions = {
+ fetchOpts: {
+ callbacks: {
+ certificateCheck: () => 1,
+ },
+ },
+ };
+
+ return new Promise(resolve => {
+ if (!fs.existsSync(p)) {
+ rimraf(p, error => {
+ Git.Clone.clone(url, p, opts).then(repo => {
+ resolve(repo);
+ });
+ });
+ } else {
+ resolve();
+ }
+ });
+}
+
+const repoUri = 'github.com/Microsoft/TypeScript-Node-Starter';
+
+const serverOptions = createTestServerOption();
+
+function cleanWorkspace() {
+ return new Promise(resolve => {
+ rimraf(serverOptions.workspacePath, resolve);
+ });
+}
+
+function setupEsClientSpy() {
+ // Mock a git status of the repo indicating the the repo is fully cloned already.
+ const getSpy = sinon.fake.returns(
+ Promise.resolve({
+ _source: {
+ [RepositoryGitStatusReservedField]: {
+ uri: 'github.com/Microsoft/TypeScript-Node-Starter',
+ progress: WorkerReservedProgress.COMPLETED,
+ timestamp: new Date(),
+ cloneProgress: {
+ isCloned: true,
+ },
+ },
+ },
+ })
+ );
+ const existsAliasSpy = sinon.fake.returns(false);
+ const createSpy = sinon.spy();
+ const putAliasSpy = sinon.spy();
+ const deleteByQuerySpy = sinon.spy();
+ const bulkSpy = sinon.spy();
+ esClient.bulk = bulkSpy;
+ esClient.indices.existsAlias = existsAliasSpy;
+ esClient.indices.create = createSpy;
+ esClient.indices.putAlias = putAliasSpy;
+ esClient.get = getSpy;
+ esClient.deleteByQuery = deleteByQuerySpy;
+ return {
+ getSpy,
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ };
+}
+
+function setupLsServiceSendRequestSpy(): sinon.SinonSpy {
+ return sinon.fake.returns(
+ Promise.resolve({
+ result: [
+ {
+ // 1 mock symbol for each file
+ symbols: [
+ {
+ symbolInformation: {
+ name: 'mocksymbolname',
+ },
+ },
+ ],
+ // 1 mock reference for each file
+ references: [{}],
+ },
+ ],
+ })
+ );
+}
+
+describe('lsp_incremental_indexer unit tests', () => {
+ // @ts-ignore
+ before(async () => {
+ return new Promise(resolve => {
+ rimraf(serverOptions.repoPath, resolve);
+ });
+ });
+
+ beforeEach(async function() {
+ // @ts-ignore
+ this.timeout(200000);
+ return await prepareProject(
+ 'https://github.com/Microsoft/TypeScript-Node-Starter.git',
+ path.join(serverOptions.repoPath, repoUri)
+ );
+ });
+ // @ts-ignore
+ after(() => {
+ return cleanWorkspace();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('Normal LSP index process.', async () => {
+ // Setup the esClient spies
+ const {
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ } = setupEsClientSpy();
+
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient as EsClient,
+ {} as InstallManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient as EsClient)
+ );
+
+ lspservice.sendRequest = setupLsServiceSendRequestSpy();
+
+ const indexer = new LspIncrementalIndexer(
+ 'github.com/Microsoft/TypeScript-Node-Starter',
+ '4779cb7e',
+ '6206f643',
+ lspservice,
+ serverOptions,
+ esClient as EsClient,
+ log
+ );
+ await indexer.start();
+
+ // Index and alias creation are not necessary for incremental indexing
+ assert.strictEqual(existsAliasSpy.callCount, 0);
+ assert.strictEqual(createSpy.callCount, 0);
+ assert.strictEqual(putAliasSpy.callCount, 0);
+
+ // DeletebyQuery is called 6 times (1 file + 1 symbol reuqests per diff item)
+ // for 3 MODIFIED items
+ assert.strictEqual(deleteByQuerySpy.callCount, 6);
+
+ // There are 3 MODIFIED items. 1 file + 1 symbol + 1 reference = 3 objects to
+ // index for each item. Total doc indexed should be 3 * 3 = 9, which can be
+ // fitted into a single batch index.
+ assert.ok(bulkSpy.calledOnce);
+ assert.strictEqual(bulkSpy.getCall(0).args[0].body.length, 9 * 2);
+ // @ts-ignore
+ }).timeout(20000);
+
+ it('Cancel LSP index process.', async () => {
+ // Setup the esClient spies
+ const {
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ } = setupEsClientSpy();
+
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient as EsClient,
+ {} as InstallManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient as EsClient)
+ );
+
+ lspservice.sendRequest = setupLsServiceSendRequestSpy();
+
+ const indexer = new LspIncrementalIndexer(
+ 'github.com/Microsoft/TypeScript-Node-Starter',
+ '4779cb7e',
+ '6206f643',
+ lspservice,
+ serverOptions,
+ esClient as EsClient,
+ log
+ );
+ // Cancel the indexer before start.
+ indexer.cancel();
+ await indexer.start();
+
+ // Index and alias creation are not necessary for incremental indexing.
+ assert.strictEqual(existsAliasSpy.callCount, 0);
+ assert.strictEqual(createSpy.callCount, 0);
+ assert.strictEqual(putAliasSpy.callCount, 0);
+
+ // Because the indexer is cancelled already in the begining. 0 doc should be
+ // indexed and thus bulk and deleteByQuery won't be called.
+ assert.ok(bulkSpy.notCalled);
+ assert.ok(deleteByQuerySpy.notCalled);
+ });
+
+ it('Index continues from a checkpoint', async () => {
+ // Setup the esClient spies
+ const {
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ } = setupEsClientSpy();
+
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient as EsClient,
+ {} as InstallManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient as EsClient)
+ );
+
+ lspservice.sendRequest = setupLsServiceSendRequestSpy();
+
+ const indexer = new LspIncrementalIndexer(
+ 'github.com/Microsoft/TypeScript-Node-Starter',
+ '46971a84',
+ '6206f643',
+ lspservice,
+ serverOptions,
+ esClient as EsClient,
+ log
+ );
+
+ // Apply a checkpoint in here.
+ await indexer.start(undefined, {
+ repoUri: '',
+ filePath: 'package.json',
+ revision: '46971a84',
+ originRevision: '6206f643',
+ localRepoPath: '',
+ kind: DiffKind.MODIFIED,
+ });
+
+ // Index and alias creation are not necessary for incremental indexing.
+ assert.strictEqual(existsAliasSpy.callCount, 0);
+ assert.strictEqual(createSpy.callCount, 0);
+ assert.strictEqual(putAliasSpy.callCount, 0);
+
+ // There are 3 MODIFIED items, but 1 item after the checkpoint. 1 file
+ // + 1 symbol + 1 ref = 3 objects to be indexed for each item. Total doc
+ // indexed should be 3 * 2 = 2, which can be fitted into a single batch index.
+ assert.ok(bulkSpy.calledOnce);
+ assert.strictEqual(bulkSpy.getCall(0).args[0].body.length, 3 * 2);
+ assert.strictEqual(deleteByQuerySpy.callCount, 2);
+ // @ts-ignore
+ }).timeout(20000);
+ // @ts-ignore
+}).timeout(20000);
diff --git a/x-pack/plugins/code/server/__tests__/lsp_indexer.ts b/x-pack/plugins/code/server/__tests__/lsp_indexer.ts
new file mode 100644
index 0000000000000..4773e5ab35a58
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/lsp_indexer.ts
@@ -0,0 +1,303 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import Git, { CloneOptions } from '@elastic/nodegit';
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import rimraf from 'rimraf';
+import sinon from 'sinon';
+
+import { WorkerReservedProgress } from '../../model';
+import { LspIndexer } from '../indexer/lsp_indexer';
+import { RepositoryGitStatusReservedField } from '../indexer/schema';
+import { EsClient } from '../lib/esqueue';
+import { Logger } from '../log';
+import { InstallManager } from '../lsp/install_manager';
+import { LspService } from '../lsp/lsp_service';
+import { RepositoryConfigController } from '../repository_config_controller';
+import { createTestServerOption, emptyAsyncFunc } from '../test_utils';
+import { ConsoleLoggerFactory } from '../utils/console_logger_factory';
+
+const log: Logger = new ConsoleLoggerFactory().getLogger(['test']);
+
+const esClient = {
+ bulk: emptyAsyncFunc,
+ get: emptyAsyncFunc,
+ deleteByQuery: emptyAsyncFunc,
+ indices: {
+ existsAlias: emptyAsyncFunc,
+ create: emptyAsyncFunc,
+ putAlias: emptyAsyncFunc,
+ },
+};
+
+function prepareProject(url: string, p: string) {
+ const opts: CloneOptions = {
+ fetchOpts: {
+ callbacks: {
+ certificateCheck: () => 1,
+ },
+ },
+ };
+
+ return new Promise(resolve => {
+ if (!fs.existsSync(p)) {
+ rimraf(p, error => {
+ Git.Clone.clone(url, p, opts).then(repo => {
+ resolve(repo);
+ });
+ });
+ } else {
+ resolve();
+ }
+ });
+}
+
+const repoUri = 'github.com/Microsoft/TypeScript-Node-Starter';
+
+const serverOptions = createTestServerOption();
+
+function cleanWorkspace() {
+ return new Promise(resolve => {
+ rimraf(serverOptions.workspacePath, resolve);
+ });
+}
+
+function setupEsClientSpy() {
+ // Mock a git status of the repo indicating the the repo is fully cloned already.
+ const getSpy = sinon.fake.returns(
+ Promise.resolve({
+ _source: {
+ [RepositoryGitStatusReservedField]: {
+ uri: 'github.com/Microsoft/TypeScript-Node-Starter',
+ progress: WorkerReservedProgress.COMPLETED,
+ timestamp: new Date(),
+ cloneProgress: {
+ isCloned: true,
+ },
+ },
+ },
+ })
+ );
+ const existsAliasSpy = sinon.fake.returns(false);
+ const createSpy = sinon.spy();
+ const putAliasSpy = sinon.spy();
+ const deleteByQuerySpy = sinon.spy();
+ const bulkSpy = sinon.spy();
+ esClient.bulk = bulkSpy;
+ esClient.indices.existsAlias = existsAliasSpy;
+ esClient.indices.create = createSpy;
+ esClient.indices.putAlias = putAliasSpy;
+ esClient.get = getSpy;
+ esClient.deleteByQuery = deleteByQuerySpy;
+ return {
+ getSpy,
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ };
+}
+
+function setupLsServiceSendRequestSpy(): sinon.SinonSpy {
+ return sinon.fake.returns(
+ Promise.resolve({
+ result: [
+ {
+ // 1 mock symbol for each file
+ symbols: [
+ {
+ symbolInformation: {
+ name: 'mocksymbolname',
+ },
+ },
+ ],
+ // 1 mock reference for each file
+ references: [{}],
+ },
+ ],
+ })
+ );
+}
+describe('lsp_indexer unit tests', () => {
+ // @ts-ignore
+ before(async () => {
+ return new Promise(resolve => {
+ rimraf(serverOptions.repoPath, resolve);
+ });
+ });
+
+ beforeEach(async function() {
+ // @ts-ignore
+ this.timeout(200000);
+ return await prepareProject(
+ 'https://github.com/Microsoft/TypeScript-Node-Starter.git',
+ path.join(serverOptions.repoPath, repoUri)
+ );
+ });
+ // @ts-ignore
+ after(() => {
+ return cleanWorkspace();
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('Normal LSP index process.', async () => {
+ // Setup the esClient spies
+ const {
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ } = setupEsClientSpy();
+
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient as EsClient,
+ {} as InstallManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient as EsClient)
+ );
+
+ lspservice.sendRequest = setupLsServiceSendRequestSpy();
+
+ const indexer = new LspIndexer(
+ 'github.com/Microsoft/TypeScript-Node-Starter',
+ 'master',
+ lspservice,
+ serverOptions,
+ esClient as EsClient,
+ log
+ );
+ await indexer.start();
+
+ // Expect EsClient deleteByQuery called 3 times for repository cleaning before
+ // the index for document, symbol and reference, respectively.
+ assert.strictEqual(deleteByQuerySpy.callCount, 3);
+
+ // Ditto for index and alias creation
+ assert.strictEqual(existsAliasSpy.callCount, 3);
+ assert.strictEqual(createSpy.callCount, 3);
+ assert.strictEqual(putAliasSpy.callCount, 3);
+
+ // There are 22 files in the repo. 1 file + 1 symbol + 1 reference = 3 objects to
+ // index for each file. Total doc indexed should be 3 * 22 = 66, which can be
+ // fitted into a single batch index.
+ assert.ok(bulkSpy.calledOnce);
+ assert.strictEqual(bulkSpy.getCall(0).args[0].body.length, 66 * 2);
+ // @ts-ignore
+ }).timeout(20000);
+
+ it('Cancel LSP index process.', async () => {
+ // Setup the esClient spies
+ const {
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ } = setupEsClientSpy();
+
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient as EsClient,
+ {} as InstallManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient as EsClient)
+ );
+
+ lspservice.sendRequest = setupLsServiceSendRequestSpy();
+
+ const indexer = new LspIndexer(
+ 'github.com/Microsoft/TypeScript-Node-Starter',
+ 'master',
+ lspservice,
+ serverOptions,
+ esClient as EsClient,
+ log
+ );
+ // Cancel the indexer before start.
+ indexer.cancel();
+ await indexer.start();
+
+ // Expect EsClient deleteByQuery called 3 times for repository cleaning before
+ // the index for document, symbol and reference, respectively.
+ assert.strictEqual(deleteByQuerySpy.callCount, 3);
+
+ // Ditto for index and alias creation
+ assert.strictEqual(existsAliasSpy.callCount, 3);
+ assert.strictEqual(createSpy.callCount, 3);
+ assert.strictEqual(putAliasSpy.callCount, 3);
+
+ // Because the indexer is cancelled already in the begining. 0 doc should be
+ // indexed and thus bulk won't be called.
+ assert.ok(bulkSpy.notCalled);
+ });
+
+ it('Index continues from a checkpoint', async () => {
+ // Setup the esClient spies
+ const {
+ existsAliasSpy,
+ createSpy,
+ putAliasSpy,
+ deleteByQuerySpy,
+ bulkSpy,
+ } = setupEsClientSpy();
+
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient as EsClient,
+ {} as InstallManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient as EsClient)
+ );
+
+ lspservice.sendRequest = setupLsServiceSendRequestSpy();
+
+ const indexer = new LspIndexer(
+ 'github.com/Microsoft/TypeScript-Node-Starter',
+ '46971a8',
+ lspservice,
+ serverOptions,
+ esClient as EsClient,
+ log
+ );
+
+ // Apply a checkpoint in here.
+ await indexer.start(undefined, {
+ repoUri: '',
+ filePath: 'src/public/js/main.ts',
+ revision: '46971a8',
+ localRepoPath: '',
+ });
+
+ // Expect EsClient deleteByQuery called 0 times for repository cleaning while
+ // dealing with repository checkpoint.
+ assert.strictEqual(deleteByQuerySpy.callCount, 0);
+
+ // Ditto for index and alias creation
+ assert.strictEqual(existsAliasSpy.callCount, 0);
+ assert.strictEqual(createSpy.callCount, 0);
+ assert.strictEqual(putAliasSpy.callCount, 0);
+
+ // There are 22 files in the repo, but only 11 files after the checkpoint.
+ // 1 file + 1 symbol + 1 reference = 3 objects to index for each file.
+ // Total doc indexed should be 3 * 11 = 33, which can be fitted into a
+ // single batch index.
+ assert.ok(bulkSpy.calledOnce);
+ assert.strictEqual(bulkSpy.getCall(0).args[0].body.length, 33 * 2);
+ // @ts-ignore
+ }).timeout(20000);
+ // @ts-ignore
+}).timeout(20000);
diff --git a/x-pack/plugins/code/server/__tests__/lsp_service.ts b/x-pack/plugins/code/server/__tests__/lsp_service.ts
new file mode 100644
index 0000000000000..0bb557323db11
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/lsp_service.ts
@@ -0,0 +1,203 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import Git from '@elastic/nodegit';
+import fs from 'fs';
+import mkdirp from 'mkdirp';
+import path from 'path';
+// import rimraf from 'rimraf';
+import sinon from 'sinon';
+
+import assert from 'assert';
+import { Server } from 'hapi';
+import { RepositoryConfigReservedField, RepositoryGitStatusReservedField } from '../indexer/schema';
+import { InstallManager } from '../lsp/install_manager';
+import { LspService } from '../lsp/lsp_service';
+import { RepositoryConfigController } from '../repository_config_controller';
+import { createTestServerOption } from '../test_utils';
+import { ConsoleLoggerFactory } from '../utils/console_logger_factory';
+
+const filename = 'hello.ts';
+describe('lsp_service tests', () => {
+ async function prepareProject(repoPath: string) {
+ mkdirp.sync(repoPath);
+ const repo = await Git.Repository.init(repoPath, 0);
+ const helloContent = "console.log('hello world');";
+ fs.writeFileSync(path.join(repo.workdir(), filename), helloContent, 'utf8');
+ const index = await repo.refreshIndex();
+ await index.addByPath(filename);
+ index.write();
+ const treeId = await index.writeTree();
+ const committer = Git.Signature.create('tester', 'test@test.com', Date.now() / 1000, 60);
+ const commit = await repo.createCommit(
+ 'HEAD',
+ committer,
+ committer,
+ 'commit for test',
+ treeId,
+ []
+ );
+ // eslint-disable-next-line no-console
+ console.log(`created commit ${commit.tostrS()}`);
+ return repo;
+ }
+
+ const serverOptions = createTestServerOption();
+ const installManager = new InstallManager(new Server(), serverOptions);
+
+ function mockEsClient(): any {
+ const api = {
+ get(params: any) {
+ return {
+ _source: {
+ [RepositoryGitStatusReservedField]: {
+ cloneProgress: {
+ isCloned: true,
+ },
+ },
+ [RepositoryConfigReservedField]: {
+ disableTypescript: false,
+ },
+ },
+ };
+ },
+ };
+ return api;
+ }
+
+ const repoUri = 'github.com/test/test_repo';
+
+ // @ts-ignore
+ before(async () => {
+ await prepareProject(path.join(serverOptions.repoPath, repoUri));
+ });
+
+ function comparePath(pathA: string, pathB: string) {
+ const pa = fs.realpathSync(pathA);
+ const pb = fs.realpathSync(pathB);
+ return path.resolve(pa) === path.resolve(pb);
+ }
+
+ it('process a hover request', async () => {
+ const esClient = mockEsClient();
+ const revision = 'master';
+
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient,
+ installManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient)
+ );
+ try {
+ const params = {
+ textDocument: {
+ uri: `git://${repoUri}/blob/${revision}/${filename}`,
+ },
+ position: {
+ line: 0,
+ character: 1,
+ },
+ };
+ const workspaceHandler = lspservice.workspaceHandler;
+ const wsSpy = sinon.spy(workspaceHandler, 'handleRequest');
+ const controller = lspservice.controller;
+ const ctrlSpy = sinon.spy(controller, 'handleRequest');
+
+ const method = 'textDocument/hover';
+
+ const response = await lspservice.sendRequest(method, params);
+ assert.ok(response);
+ assert.ok(response.result.contents);
+
+ wsSpy.restore();
+ ctrlSpy.restore();
+
+ const workspaceFolderExists = fs.existsSync(
+ path.join(serverOptions.workspacePath, repoUri, revision)
+ );
+ // workspace is opened
+ assert.ok(workspaceFolderExists);
+
+ const workspacePath = fs.realpathSync(
+ path.resolve(serverOptions.workspacePath, repoUri, revision)
+ );
+ // workspace handler is working, filled workspacePath
+ sinon.assert.calledWith(
+ ctrlSpy,
+ sinon.match.has('workspacePath', sinon.match(value => comparePath(value, workspacePath)))
+ );
+ // uri is changed by workspace handler
+ sinon.assert.calledWith(
+ ctrlSpy,
+ sinon.match.hasNested('params.textDocument.uri', `file://${workspacePath}/${filename}`)
+ );
+ return;
+ } finally {
+ await lspservice.shutdown();
+ }
+ // @ts-ignore
+ }).timeout(10000);
+
+ it('unload a workspace', async () => {
+ const esClient = mockEsClient();
+ const revision = 'master';
+ const lspservice = new LspService(
+ '127.0.0.1',
+ serverOptions,
+ esClient,
+ installManager,
+ new ConsoleLoggerFactory(),
+ new RepositoryConfigController(esClient)
+ );
+ try {
+ const params = {
+ textDocument: {
+ uri: `git://${repoUri}/blob/${revision}/${filename}`,
+ },
+ position: {
+ line: 0,
+ character: 1,
+ },
+ };
+
+ const method = 'textDocument/hover';
+ // send a dummy request to open a workspace;
+ const response = await lspservice.sendRequest(method, params);
+ assert.ok(response);
+ const workspacePath = path.resolve(serverOptions.workspacePath, repoUri, revision);
+ const workspaceFolderExists = fs.existsSync(workspacePath);
+ // workspace is opened
+ assert.ok(workspaceFolderExists);
+ const controller = lspservice.controller;
+ // @ts-ignore
+ const languageServer = controller.languageServerMap.typescript;
+ const realWorkspacePath = fs.realpathSync(workspacePath);
+
+ // @ts-ignore
+ const handler = languageServer.languageServerHandlers[realWorkspacePath];
+ const exitSpy = sinon.spy(handler, 'exit');
+ const unloadSpy = sinon.spy(handler, 'unloadWorkspace');
+
+ await lspservice.deleteWorkspace(repoUri);
+
+ unloadSpy.restore();
+ exitSpy.restore();
+
+ sinon.assert.calledWith(unloadSpy, realWorkspacePath);
+ // typescript language server for this workspace should be closed
+ sinon.assert.calledOnce(exitSpy);
+ // the workspace folder should be deleted
+ const exists = fs.existsSync(realWorkspacePath);
+ assert.strictEqual(exists, false);
+ return;
+ } finally {
+ await lspservice.shutdown();
+ }
+ // @ts-ignore
+ }).timeout(10000);
+});
diff --git a/x-pack/plugins/code/server/__tests__/multi_node.ts b/x-pack/plugins/code/server/__tests__/multi_node.ts
new file mode 100644
index 0000000000000..68b293717a9dc
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/multi_node.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import getPort from 'get-port';
+import { resolve } from 'path';
+import { Root } from 'src/core/server/root';
+import {
+ createRootWithCorePlugins,
+ request,
+ startTestServers,
+} from '../../../../../src/test_utils/kbn_server';
+
+describe('code in multiple nodes', () => {
+ const codeNodeUuid = 'c4add484-0cba-4e05-86fe-4baa112d9e53';
+ const nonodeNodeUuid = '22b75e04-0e50-4647-9643-6b1b1d88beaf';
+ let codePort: number;
+ let nonCodePort: number;
+ let codeNode: Root;
+ let nonCodeNode: Root;
+
+ let servers: any;
+ const pluginPaths = resolve(__dirname, '../../../../../x-pack');
+
+ async function startServers() {
+ codePort = await getPort();
+ nonCodePort = await getPort();
+ servers = await startTestServers({
+ adjustTimeout: t => {
+ // @ts-ignore
+ this.timeout(t);
+ },
+ settings: {
+ kbn: {
+ server: {
+ uuid: codeNodeUuid,
+ port: codePort,
+ },
+ plugins: { paths: [pluginPaths] },
+ xpack: {
+ upgrade_assistant: {
+ enabled: false,
+ },
+ security: {
+ enabled: false,
+ },
+ },
+ },
+ },
+ });
+ codeNode = servers.root;
+ await startNonCodeNodeKibana();
+ }
+
+ async function startNonCodeNodeKibana() {
+ const setting = {
+ server: {
+ port: nonCodePort,
+ uuid: nonodeNodeUuid,
+ },
+ plugins: { paths: [pluginPaths] },
+ xpack: {
+ upgrade_assistant: {
+ enabled: false,
+ },
+ code: { codeNodeUrl: `http://localhost:${codePort}` },
+ security: {
+ enabled: false,
+ },
+ },
+ };
+ nonCodeNode = createRootWithCorePlugins(setting);
+ await nonCodeNode.setup();
+ }
+ // @ts-ignore
+ before(startServers);
+
+ // @ts-ignore
+ after(async function() {
+ // @ts-ignore
+ this.timeout(10000);
+ await nonCodeNode.shutdown();
+ await servers.stop();
+ });
+
+ function delay(ms: number) {
+ return new Promise(resolve1 => {
+ setTimeout(resolve1, ms);
+ });
+ }
+
+ it('Code node setup should be ok', async () => {
+ await request.get(codeNode, '/api/code/setup').expect(200);
+ });
+
+ it('Non-code node setup should be ok', async () => {
+ await request.get(nonCodeNode, '/api/code/setup').expect(200);
+ });
+
+ it('Non-code node setup should fail if code node is shutdown', async () => {
+ await codeNode.shutdown();
+ await delay(2000);
+ await request.get(nonCodeNode, '/api/code/setup').expect(502);
+ await codeNode.setup();
+ await delay(2000);
+ await request.get(nonCodeNode, '/api/code/setup').expect(200);
+ // @ts-ignore
+ }).timeout(20000);
+});
diff --git a/x-pack/plugins/code/server/__tests__/repository_service.ts b/x-pack/plugins/code/server/__tests__/repository_service.ts
new file mode 100644
index 0000000000000..7eaec9af646de
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/repository_service.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import assert from 'assert';
+// import { generateKeyPairSync } from 'crypto';
+import fs from 'fs';
+import * as os from 'os';
+import path from 'path';
+import rimraf from 'rimraf';
+import { RepositoryUtils } from '../../common/repository_utils';
+import { RepositoryService } from '../repository_service';
+import { ConsoleLogger } from '../utils/console_logger';
+
+describe('repository service test', () => {
+ const log = new ConsoleLogger();
+ const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code_test'));
+ log.debug(baseDir);
+ const repoDir = path.join(baseDir, 'repo');
+ const credsDir = path.join(baseDir, 'credentials');
+ // @ts-ignore
+ before(() => {
+ fs.mkdirSync(credsDir);
+ fs.mkdirSync(repoDir);
+ });
+ // @ts-ignore
+ after(() => {
+ return rimraf.sync(baseDir);
+ });
+ const service = new RepositoryService(repoDir, credsDir, log, false /* enableGitCertCheck */);
+
+ it('can not clone a repo by ssh without a key', async () => {
+ const repo = RepositoryUtils.buildRepository(
+ 'git@github.com:elastic/TypeScript-Node-Starter.git'
+ );
+ await assert.rejects(service.clone(repo));
+ // @ts-ignore
+ }).timeout(60000);
+
+ /* it('can clone a repo by ssh with a key', async () => {
+
+ const repo = RepositoryUtils.buildRepository('git@github.com:elastic/code.git');
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
+ modulusLength: 4096,
+ publicKeyEncoding: {
+ type: 'pkcs1',
+ format: 'pem',
+ },
+ privateKeyEncoding: {
+ type: 'pkcs1',
+ format: 'pem',
+ },
+ });
+ fs.writeFileSync(path.join(credsDir, 'id_rsa.pub'), publicKey);
+ fs.writeFileSync(path.join(credsDir, 'id_rsa'), privateKey);
+ const result = await service.clone(repo);
+ assert.ok(fs.existsSync(path.join(repoDir, result.repo.uri)));
+ }).timeout(60000); */
+});
diff --git a/x-pack/plugins/code/server/__tests__/workspace_handler.ts b/x-pack/plugins/code/server/__tests__/workspace_handler.ts
new file mode 100644
index 0000000000000..535a1effe8059
--- /dev/null
+++ b/x-pack/plugins/code/server/__tests__/workspace_handler.ts
@@ -0,0 +1,181 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import fs from 'fs';
+import path from 'path';
+
+import Git from '@elastic/nodegit';
+import assert from 'assert';
+import mkdirp from 'mkdirp';
+import * as os from 'os';
+import rimraf from 'rimraf';
+import { ResponseMessage } from 'vscode-jsonrpc/lib/messages';
+import { LspRequest } from '../../model';
+import { WorkspaceHandler } from '../lsp/workspace_handler';
+import { ConsoleLoggerFactory } from '../utils/console_logger_factory';
+
+const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code_test'));
+const workspaceDir = path.join(baseDir, 'workspace');
+const repoDir = path.join(baseDir, 'repo');
+
+describe('workspace_handler tests', () => {
+ function handleResponseUri(wh: WorkspaceHandler, uri: string) {
+ const dummyRequest: LspRequest = {
+ method: 'textDocument/edefinition',
+ params: [],
+ };
+ const dummyResponse: ResponseMessage = {
+ id: null,
+ jsonrpc: '',
+ result: [
+ {
+ location: {
+ uri,
+ },
+ },
+ ],
+ };
+ wh.handleResponse(dummyRequest, dummyResponse);
+ return dummyResponse.result[0].location.uri;
+ }
+
+ function makeAFile(
+ workspacePath: string = workspaceDir,
+ repo = 'github.com/Microsoft/TypeScript-Node-Starter',
+ revision = 'master',
+ file = 'src/controllers/user.ts'
+ ) {
+ const fullPath = path.join(workspacePath, repo, '__randomString', revision, file);
+ mkdirp.sync(path.dirname(fullPath));
+ fs.writeFileSync(fullPath, '');
+ const strInUrl = fullPath
+ .split(path.sep)
+ .map(value => encodeURIComponent(value))
+ .join('/');
+ const uri = `file:///${strInUrl}`;
+ return { repo, revision, file, uri };
+ }
+
+ it('file system url should be converted', async () => {
+ const workspaceHandler = new WorkspaceHandler(
+ repoDir,
+ workspaceDir,
+ // @ts-ignore
+ null,
+ new ConsoleLoggerFactory()
+ );
+ const { repo, revision, file, uri } = makeAFile(workspaceDir);
+ const converted = handleResponseUri(workspaceHandler, uri);
+ assert.strictEqual(converted, `git://${repo}/blob/${revision}/${file}`);
+ });
+
+ it('should support symbol link', async () => {
+ const symlinkToWorkspace = path.join(baseDir, 'linkWorkspace');
+ fs.symlinkSync(workspaceDir, symlinkToWorkspace, 'dir');
+ // @ts-ignore
+ const workspaceHandler = new WorkspaceHandler(
+ repoDir,
+ symlinkToWorkspace,
+ // @ts-ignore
+ null,
+ new ConsoleLoggerFactory()
+ );
+
+ const { repo, revision, file, uri } = makeAFile(workspaceDir);
+ const converted = handleResponseUri(workspaceHandler, uri);
+ assert.strictEqual(converted, `git://${repo}/blob/${revision}/${file}`);
+ });
+
+ it('should support spaces in workspace dir', async () => {
+ const workspaceHasSpaces = path.join(baseDir, 'work space');
+ const workspaceHandler = new WorkspaceHandler(
+ repoDir,
+ workspaceHasSpaces,
+ // @ts-ignore
+ null,
+ new ConsoleLoggerFactory()
+ );
+ const { repo, revision, file, uri } = makeAFile(workspaceHasSpaces);
+ const converted = handleResponseUri(workspaceHandler, uri);
+ assert.strictEqual(converted, `git://${repo}/blob/${revision}/${file}`);
+ });
+
+ it('should throw a error if url is invalid', async () => {
+ const workspaceHandler = new WorkspaceHandler(
+ repoDir,
+ workspaceDir,
+ // @ts-ignore
+ null,
+ new ConsoleLoggerFactory()
+ );
+ const invalidDir = path.join(baseDir, 'invalid_dir');
+ const { uri } = makeAFile(invalidDir);
+ assert.throws(() => handleResponseUri(workspaceHandler, uri));
+ });
+
+ async function prepareProject(repoPath: string) {
+ mkdirp.sync(repoPath);
+ const repo = await Git.Repository.init(repoPath, 0);
+ const content = 'console.log("test")';
+ const subFolder = 'src';
+ fs.mkdirSync(path.join(repo.workdir(), subFolder));
+ fs.writeFileSync(path.join(repo.workdir(), 'src/app.ts'), content, 'utf8');
+
+ const index = await repo.refreshIndex();
+ await index.addByPath('src/app.ts');
+ index.write();
+ const treeId = await index.writeTree();
+ const committer = Git.Signature.create('tester', 'test@test.com', Date.now() / 1000, 60);
+ const commit = await repo.createCommit(
+ 'HEAD',
+ committer,
+ committer,
+ 'commit for test',
+ treeId,
+ []
+ );
+ return { repo, commit };
+ }
+
+ it('should throw a error if file path is external', async () => {
+ const workspaceHandler = new WorkspaceHandler(
+ repoDir,
+ workspaceDir,
+ // @ts-ignore
+ null,
+ new ConsoleLoggerFactory()
+ );
+ const repoUri = 'github.com/microsoft/typescript-node-starter';
+ await prepareProject(path.join(repoDir, repoUri));
+ const externalFile = 'node_modules/abbrev/abbrev.js';
+ const request: LspRequest = {
+ method: 'textDocument/hover',
+ params: {
+ position: {
+ line: 8,
+ character: 23,
+ },
+ textDocument: {
+ uri: `git://${repoUri}/blob/master/${externalFile}`,
+ },
+ },
+ };
+ assert.rejects(
+ workspaceHandler.handleRequest(request),
+ new Error('invalid fle path in requests.')
+ );
+ });
+
+ // @ts-ignore
+ before(() => {
+ mkdirp.sync(workspaceDir);
+ mkdirp.sync(repoDir);
+ });
+
+ // @ts-ignore
+ after(() => {
+ rimraf.sync(baseDir);
+ });
+});
diff --git a/x-pack/plugins/code/server/check_repos.ts b/x-pack/plugins/code/server/check_repos.ts
new file mode 100644
index 0000000000000..4b23ecff2dc3d
--- /dev/null
+++ b/x-pack/plugins/code/server/check_repos.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import fs from 'fs';
+import { RepositoryUtils } from '../common/repository_utils';
+import { EsClient } from './lib/esqueue';
+import { Logger } from './log';
+import { CloneWorker } from './queue';
+import { RepositoryObjectClient } from './search';
+import { ServerOptions } from './server_options';
+
+export async function checkRepos(
+ cloneWorker: CloneWorker,
+ esClient: EsClient,
+ serverOptions: ServerOptions,
+ log: Logger
+) {
+ log.info('Check repositories on local disk.');
+ const repoObjectClient = new RepositoryObjectClient(esClient);
+ const repos = await repoObjectClient.getAllRepositories();
+ for (const repo of repos) {
+ try {
+ const path = RepositoryUtils.repositoryLocalPath(serverOptions.repoPath, repo.uri);
+ if (!fs.existsSync(path)) {
+ log.info(`can't find ${repo.uri} on local disk, cloning from remote.`);
+ const payload = {
+ url: repo.url,
+ };
+ await cloneWorker.enqueueJob(payload, {});
+ }
+ } catch (e) {
+ log.error(e);
+ }
+ }
+}
diff --git a/x-pack/plugins/code/server/git_operations.ts b/x-pack/plugins/code/server/git_operations.ts
new file mode 100644
index 0000000000000..6f0a4267982bd
--- /dev/null
+++ b/x-pack/plugins/code/server/git_operations.ts
@@ -0,0 +1,563 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/camelcase */
+
+import {
+ Blame,
+ Commit,
+ Diff as NodeGitDiff,
+ Error,
+ Object,
+ Oid,
+ Reference,
+ Repository,
+ Tree,
+ TreeEntry,
+} from '@elastic/nodegit';
+import Boom from 'boom';
+import * as Path from 'path';
+import { GitBlame } from '../common/git_blame';
+import { CommitDiff, Diff, DiffKind } from '../common/git_diff';
+import { FileTree, FileTreeItemType, RepositoryUri, sortFileTree } from '../model';
+import { CommitInfo, ReferenceInfo, ReferenceType } from '../model/commit';
+import { detectLanguage } from './utils/detect_language';
+
+const HEAD = 'HEAD';
+const REFS_HEADS = 'refs/heads/';
+export const DEFAULT_TREE_CHILDREN_LIMIT = 50;
+
+/**
+ * do a nodegit operation and check the results. If it throws a not found error or returns null,
+ * rethrow a Boom.notFound error.
+ * @param func the nodegit operation
+ * @param message the message pass to Boom.notFound error
+ */
+async function checkExists(func: () => Promise, message: string): Promise {
+ let result: R;
+ try {
+ result = await func();
+ } catch (e) {
+ if (e.errno === Error.CODE.ENOTFOUND) {
+ throw Boom.notFound(message);
+ } else {
+ throw e;
+ }
+ }
+ if (result == null) {
+ throw Boom.notFound(message);
+ }
+ return result;
+}
+
+function entry2Tree(entry: TreeEntry): FileTree {
+ let type: FileTreeItemType;
+ switch (entry.filemode()) {
+ case TreeEntry.FILEMODE.LINK:
+ type = FileTreeItemType.Link;
+ break;
+ case TreeEntry.FILEMODE.COMMIT:
+ type = FileTreeItemType.Submodule;
+ break;
+ case TreeEntry.FILEMODE.TREE:
+ type = FileTreeItemType.Directory;
+ break;
+ case TreeEntry.FILEMODE.BLOB:
+ case TreeEntry.FILEMODE.EXECUTABLE:
+ type = FileTreeItemType.File;
+ break;
+ default:
+ // @ts-ignore
+ throw new Error('unreadable file');
+ }
+ return {
+ name: entry.name(),
+ path: entry.path(),
+ sha1: entry.sha(),
+ type,
+ };
+}
+
+export class GitOperations {
+ private repoRoot: string;
+
+ constructor(repoRoot: string) {
+ this.repoRoot = repoRoot;
+ }
+
+ public async fileContent(uri: RepositoryUri, path: string, revision: string = 'master') {
+ const repo = await this.openRepo(uri);
+ const commit = await this.getCommit(repo, revision);
+ const entry: TreeEntry = await checkExists(
+ () => commit.getEntry(path),
+ `file ${uri}/${path} not found `
+ );
+ if (entry.isFile() || entry.filemode() === TreeEntry.FILEMODE.LINK) {
+ return await entry.getBlob();
+ } else {
+ throw Boom.unsupportedMediaType(`${uri}/${path} is not a file.`);
+ }
+ }
+
+ public async getCommit(repo: Repository, revision: string): Promise {
+ if (revision.toUpperCase() === 'HEAD') {
+ return await repo.getHeadCommit();
+ }
+ // branches and tags
+ const refs = [`refs/remotes/origin/${revision}`, `refs/tags/${revision}`];
+ const commit = await this.findCommitByRefs(repo, refs);
+ if (commit === null) {
+ return (await checkExists(
+ () => this.findCommit(repo, revision),
+ `revision or branch ${revision} not found in ${repo.path()}`
+ )) as Commit;
+ }
+ return commit;
+ }
+
+ public async blame(uri: RepositoryUri, revision: string, path: string): Promise {
+ const repo = await this.openRepo(uri);
+ const newestCommit = (await this.getCommit(repo, revision)).id();
+ const blame = await Blame.file(repo, path, { newestCommit });
+ const results: GitBlame[] = [];
+ for (let i = 0; i < blame.getHunkCount(); i++) {
+ const hunk = blame.getHunkByIndex(i);
+ // @ts-ignore wrong definition in nodegit
+ const commit = await repo.getCommit(hunk.finalCommitId());
+ results.push({
+ committer: {
+ // @ts-ignore wrong definition in nodegit
+ name: hunk.finalSignature().name(),
+ // @ts-ignore wrong definition in nodegit
+ email: hunk.finalSignature().email(),
+ },
+ // @ts-ignore wrong definition in nodegit
+ startLine: hunk.finalStartLineNumber(),
+ // @ts-ignore wrong definition in nodegit
+ lines: hunk.linesInHunk(),
+ commit: {
+ id: commit.sha(),
+ message: commit.message(),
+ date: commit.date().toISOString(),
+ },
+ });
+ }
+ return results;
+ }
+
+ public async openRepo(uri: RepositoryUri): Promise {
+ const repoDir = Path.join(this.repoRoot, uri);
+ return checkExists(() => Repository.open(repoDir), `repo ${uri} not found`);
+ }
+
+ public async countRepoFiles(uri: RepositoryUri, revision: string): Promise {
+ const repo = await this.openRepo(uri);
+ const commit = await this.getCommit(repo, revision);
+ const tree = await commit.getTree();
+ let count = 0;
+
+ async function walk(t: Tree) {
+ for (const e of t.entries()) {
+ if (e.isFile() && e.filemode() !== TreeEntry.FILEMODE.LINK) {
+ count++;
+ } else if (e.isDirectory()) {
+ const subFolder = await e.getTree();
+ await walk(subFolder);
+ } else {
+ // ignore other files
+ }
+ }
+ }
+
+ await walk(tree);
+ return count;
+ }
+
+ public async iterateRepo(
+ uri: RepositoryUri,
+ revision: string
+ ): Promise> {
+ const repo = await this.openRepo(uri);
+ const commit = await this.getCommit(repo, revision);
+ const tree = await commit.getTree();
+
+ async function* walk(t: Tree): AsyncIterableIterator {
+ for (const e of t.entries()) {
+ if (e.isFile() && e.filemode() !== TreeEntry.FILEMODE.LINK) {
+ yield entry2Tree(e);
+ } else if (e.isDirectory()) {
+ const subFolder = await e.getTree();
+ await (yield* walk(subFolder));
+ } else {
+ // ignore other files
+ }
+ }
+ }
+
+ return await walk(tree);
+ }
+
+ /**
+ * Return a fileTree structure by walking the repo file tree.
+ * @param uri the repo uri
+ * @param path the start path
+ * @param revision the revision
+ * @param skip pagination parameter, skip how many nodes in each children.
+ * @param limit pagination parameter, limit the number of node's children.
+ * @param resolveParents whether the return value should always start from root
+ * @param childrenDepth how depth should the children walk.
+ */
+ public async fileTree(
+ uri: RepositoryUri,
+ path: string,
+ revision: string = HEAD,
+ skip: number = 0,
+ limit: number = DEFAULT_TREE_CHILDREN_LIMIT,
+ resolveParents: boolean = false,
+ childrenDepth: number = 1,
+ flatten: boolean = false
+ ): Promise {
+ const repo = await this.openRepo(uri);
+ const commit = await this.getCommit(repo, revision);
+ const tree = await commit.getTree();
+ if (path === '/') {
+ path = '';
+ }
+ const getRoot = async () => {
+ return await this.walkTree(
+ {
+ name: '',
+ path: '',
+ type: FileTreeItemType.Directory,
+ },
+ tree,
+ [],
+ skip,
+ limit,
+ childrenDepth,
+ flatten
+ );
+ };
+ if (path) {
+ if (resolveParents) {
+ return this.walkTree(
+ await getRoot(),
+ tree,
+ path.split('/'),
+ skip,
+ limit,
+ childrenDepth,
+ flatten
+ );
+ } else {
+ const entry = await checkExists(
+ () => Promise.resolve(tree.getEntry(path)),
+ `path ${path} does not exists.`
+ );
+ if (entry.isDirectory()) {
+ const tree1 = await entry.getTree();
+ return this.walkTree(entry2Tree(entry), tree1, [], skip, limit, childrenDepth, flatten);
+ } else {
+ return entry2Tree(entry);
+ }
+ }
+ } else {
+ return getRoot();
+ }
+ }
+
+ public async getCommitDiff(uri: string, revision: string): Promise {
+ const repo = await this.openRepo(uri);
+ const commit = await this.getCommit(repo, revision);
+ const diffs = await commit.getDiff();
+
+ const commitDiff: CommitDiff = {
+ commit: commitInfo(commit),
+ additions: 0,
+ deletions: 0,
+ files: [],
+ };
+ for (const diff of diffs) {
+ const patches = await diff.patches();
+ for (const patch of patches) {
+ const { total_deletions, total_additions } = patch.lineStats();
+ commitDiff.additions += total_additions;
+ commitDiff.deletions += total_deletions;
+ if (patch.isAdded()) {
+ const path = patch.newFile().path();
+ const modifiedCode = await this.getModifiedCode(commit, path);
+ const language = await detectLanguage(path, modifiedCode);
+ commitDiff.files.push({
+ language,
+ path,
+ modifiedCode,
+ additions: total_additions,
+ deletions: total_deletions,
+ kind: DiffKind.ADDED,
+ });
+ } else if (patch.isDeleted()) {
+ const path = patch.oldFile().path();
+ const originCode = await this.getOriginCode(commit, repo, path);
+ const language = await detectLanguage(path, originCode);
+ commitDiff.files.push({
+ language,
+ path,
+ originCode,
+ kind: DiffKind.DELETED,
+ additions: total_additions,
+ deletions: total_deletions,
+ });
+ } else if (patch.isModified()) {
+ const path = patch.newFile().path();
+ const modifiedCode = await this.getModifiedCode(commit, path);
+ const originPath = patch.oldFile().path();
+ const originCode = await this.getOriginCode(commit, repo, originPath);
+ const language = await detectLanguage(patch.newFile().path(), modifiedCode);
+ commitDiff.files.push({
+ language,
+ path,
+ originPath,
+ originCode,
+ modifiedCode,
+ kind: DiffKind.MODIFIED,
+ additions: total_additions,
+ deletions: total_deletions,
+ });
+ } else if (patch.isRenamed()) {
+ const path = patch.newFile().path();
+ commitDiff.files.push({
+ path,
+ originPath: patch.oldFile().path(),
+ kind: DiffKind.RENAMED,
+ additions: total_additions,
+ deletions: total_deletions,
+ });
+ }
+ }
+ }
+ return commitDiff;
+ }
+
+ public async getDiff(uri: string, oldRevision: string, newRevision: string): Promise {
+ const repo = await this.openRepo(uri);
+ const oldCommit = await this.getCommit(repo, oldRevision);
+ const newCommit = await this.getCommit(repo, newRevision);
+ const oldTree = await oldCommit.getTree();
+ const newTree = await newCommit.getTree();
+
+ const diff = await NodeGitDiff.treeToTree(repo, oldTree, newTree);
+
+ const res: Diff = {
+ additions: 0,
+ deletions: 0,
+ files: [],
+ };
+ const patches = await diff.patches();
+ for (const patch of patches) {
+ const { total_deletions, total_additions } = patch.lineStats();
+ res.additions += total_additions;
+ res.deletions += total_deletions;
+ if (patch.isAdded()) {
+ const path = patch.newFile().path();
+ res.files.push({
+ path,
+ additions: total_additions,
+ deletions: total_deletions,
+ kind: DiffKind.ADDED,
+ });
+ } else if (patch.isDeleted()) {
+ const path = patch.oldFile().path();
+ res.files.push({
+ path,
+ kind: DiffKind.DELETED,
+ additions: total_additions,
+ deletions: total_deletions,
+ });
+ } else if (patch.isModified()) {
+ const path = patch.newFile().path();
+ const originPath = patch.oldFile().path();
+ res.files.push({
+ path,
+ originPath,
+ kind: DiffKind.MODIFIED,
+ additions: total_additions,
+ deletions: total_deletions,
+ });
+ } else if (patch.isRenamed()) {
+ const path = patch.newFile().path();
+ res.files.push({
+ path,
+ originPath: patch.oldFile().path(),
+ kind: DiffKind.RENAMED,
+ additions: total_additions,
+ deletions: total_deletions,
+ });
+ }
+ }
+ return res;
+ }
+
+ private async getOriginCode(commit: Commit, repo: Repository, path: string) {
+ for (const oid of commit.parents()) {
+ const parentCommit = await repo.getCommit(oid);
+ if (parentCommit) {
+ const entry = await parentCommit.getEntry(path);
+ if (entry) {
+ return (await entry.getBlob()).content().toString('utf8');
+ }
+ }
+ }
+ return '';
+ }
+
+ private async getModifiedCode(commit: Commit, path: string) {
+ const entry = await commit.getEntry(path);
+ return (await entry.getBlob()).content().toString('utf8');
+ }
+
+ private async walkTree(
+ fileTree: FileTree,
+ tree: Tree,
+ paths: string[],
+ skip: number,
+ limit: number,
+ childrenDepth: number = 1,
+ flatten: boolean = false
+ ): Promise {
+ const [path, ...rest] = paths;
+ fileTree.childrenCount = tree.entryCount();
+ if (!fileTree.children) {
+ fileTree.children = [];
+ for (const e of tree.entries().slice(skip, limit)) {
+ const child = entry2Tree(e);
+ fileTree.children.push(child);
+ if (e.isDirectory()) {
+ const childChildrenCount = (await e.getTree()).entryCount();
+ if ((childChildrenCount === 1 && flatten) || childrenDepth > 1) {
+ await this.walkTree(
+ child,
+ await e.getTree(),
+ [],
+ skip,
+ limit,
+ childrenDepth - 1,
+ flatten
+ );
+ }
+ }
+ }
+ fileTree.children.sort(sortFileTree);
+ }
+ if (path) {
+ const entry = await checkExists(
+ () => Promise.resolve(tree.getEntry(path)),
+ `path ${fileTree.path}/${path} does not exists.`
+ );
+ let child = entry2Tree(entry);
+ if (entry.isDirectory()) {
+ child = await this.walkTree(
+ child,
+ await entry.getTree(),
+ rest,
+ skip,
+ limit,
+ childrenDepth,
+ flatten
+ );
+ }
+ const idx = fileTree.children.findIndex(c => c.name === entry.name());
+ if (idx >= 0) {
+ // replace the entry in children if found
+ fileTree.children[idx] = child;
+ } else {
+ fileTree.children.push(child);
+ }
+ }
+
+ return fileTree;
+ }
+
+ private async findCommit(repo: Repository, revision: string): Promise {
+ try {
+ const obj = await Object.lookupPrefix(
+ repo,
+ Oid.fromString(revision),
+ revision.length,
+ Object.TYPE.COMMIT
+ );
+ if (obj) {
+ return repo.getCommit(obj.id());
+ }
+ return null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ private async findCommitByRefs(repo: Repository, refs: string[]): Promise {
+ if (refs.length === 0) {
+ return null;
+ }
+ const [ref, ...rest] = refs;
+ try {
+ return await repo.getReferenceCommit(ref);
+ } catch (e) {
+ if (e.errno === Error.CODE.ENOTFOUND) {
+ return await this.findCommitByRefs(repo, rest);
+ } else {
+ throw e;
+ }
+ }
+ }
+}
+
+export function commitInfo(commit: Commit): CommitInfo {
+ return {
+ updated: commit.date(),
+ message: commit.message(),
+ committer: commit.committer().name(),
+ id: commit.sha().substr(0, 7),
+ parents: commit.parents().map(oid => oid.toString().substring(0, 7)),
+ };
+}
+
+export async function referenceInfo(ref: Reference): Promise {
+ const repository = ref.owner();
+ const object = await ref.peel(Object.TYPE.COMMIT);
+ const commit = await repository.getCommit(object.id());
+ let type: ReferenceType;
+ if (ref.isTag()) {
+ type = ReferenceType.TAG;
+ } else if (ref.isRemote()) {
+ type = ReferenceType.REMOTE_BRANCH;
+ } else if (ref.isBranch()) {
+ type = ReferenceType.BRANCH;
+ } else {
+ type = ReferenceType.OTHER;
+ }
+ return {
+ name: ref.shorthand(),
+ reference: ref.name(),
+ commit: commitInfo(commit),
+ type,
+ };
+}
+
+export async function getDefaultBranch(path: string): Promise {
+ const repo = await Repository.open(path);
+ const ref = await repo.getReference(HEAD);
+ const name = ref.name();
+ if (name.startsWith(REFS_HEADS)) {
+ return name.substr(REFS_HEADS.length);
+ }
+ return name;
+}
+
+export async function getHeadRevision(path: string): Promise {
+ const repo = await Repository.open(path);
+ const commit = await repo.getHeadCommit();
+ return commit.sha();
+}
diff --git a/x-pack/plugins/code/server/indexer/abstract_indexer.ts b/x-pack/plugins/code/server/indexer/abstract_indexer.ts
new file mode 100644
index 0000000000000..bc7b0b8c30ab7
--- /dev/null
+++ b/x-pack/plugins/code/server/indexer/abstract_indexer.ts
@@ -0,0 +1,189 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import moment from 'moment';
+
+import { Indexer, ProgressReporter } from '.';
+import { IndexProgress, IndexRequest, IndexStats, IndexStatsKey, RepositoryUri } from '../../model';
+import { EsClient } from '../lib/esqueue';
+import { Logger } from '../log';
+import { aggregateIndexStats } from '../utils/index_stats_aggregator';
+import { IndexCreationRequest } from './index_creation_request';
+import { IndexCreator } from './index_creator';
+
+export abstract class AbstractIndexer implements Indexer {
+ protected type: string = 'abstract';
+ protected cancelled: boolean = false;
+ protected indexCreator: IndexCreator;
+ protected INDEXER_PROGRESS_UPDATE_INTERVAL_MS = 1000;
+
+ constructor(
+ protected readonly repoUri: RepositoryUri,
+ protected readonly revision: string,
+ protected readonly client: EsClient,
+ protected readonly log: Logger
+ ) {
+ this.indexCreator = new IndexCreator(client);
+ }
+
+ public async start(progressReporter?: ProgressReporter, checkpointReq?: IndexRequest) {
+ this.log.info(
+ `Indexer ${this.type} started for repo ${this.repoUri} with revision ${this.revision}`
+ );
+ const isCheckpointValid = this.validateCheckpoint(checkpointReq);
+
+ if (this.needRefreshIndices(checkpointReq)) {
+ // Prepare the ES index
+ const res = await this.prepareIndex();
+ if (!res) {
+ this.log.error(`Prepare index for ${this.repoUri} error. Skip indexing.`);
+ return new Map();
+ }
+
+ // Clean up the index if necessary
+ await this.cleanIndex();
+ }
+
+ // Prepare all the index requests
+ let totalCount = 0;
+ let prevTimestamp = moment();
+ let successCount = 0;
+ let failCount = 0;
+ const statsBuffer: IndexStats[] = [];
+
+ try {
+ totalCount = await this.getIndexRequestCount();
+ } catch (error) {
+ this.log.error(`Get index request count for ${this.repoUri} error.`);
+ this.log.error(error);
+ throw error;
+ }
+
+ let meetCheckpoint = false;
+ const reqsIterator = await this.getIndexRequestIterator();
+ for await (const req of reqsIterator) {
+ if (this.isCancelled()) {
+ this.log.info(`Indexer cancelled. Stop right now.`);
+ break;
+ }
+
+ // If checkpoint is valid and has not been met
+ if (isCheckpointValid && !meetCheckpoint) {
+ meetCheckpoint = meetCheckpoint || this.ifCheckpointMet(req, checkpointReq!);
+ if (!meetCheckpoint) {
+ // If the checkpoint has not been met yet, skip current request.
+ continue;
+ } else {
+ this.log.info(`Checkpoint met. Continue with indexing.`);
+ }
+ }
+
+ try {
+ const stats = await this.processRequest(req);
+ statsBuffer.push(stats);
+ successCount += 1;
+ } catch (error) {
+ this.log.error(`Process index request error. ${error}`);
+ failCount += 1;
+ }
+
+ // Double check if the the indexer is cancelled or not, because the
+ // processRequest process could take fairly long and during this time
+ // the index job might have been cancelled already. In this case,
+ // we shall not update the progress.
+ if (!this.isCancelled() && progressReporter) {
+ this.log.debug(`Update progress for ${this.type} indexer.`);
+ // Update progress if progress reporter has been provided.
+ const progress: IndexProgress = {
+ type: this.type,
+ total: totalCount,
+ success: successCount,
+ fail: failCount,
+ percentage: Math.floor((100 * (successCount + failCount)) / totalCount),
+ checkpoint: req,
+ };
+ if (moment().diff(prevTimestamp) > this.INDEXER_PROGRESS_UPDATE_INTERVAL_MS) {
+ progressReporter(progress);
+ prevTimestamp = moment();
+ }
+ }
+ }
+ return aggregateIndexStats(statsBuffer);
+ }
+
+ public cancel() {
+ this.cancelled = true;
+ }
+
+ protected isCancelled(): boolean {
+ return this.cancelled;
+ }
+
+ // If the current checkpoint is valid
+ protected validateCheckpoint(checkpointReq?: IndexRequest): boolean {
+ return checkpointReq !== undefined;
+ }
+
+ // If it's necessary to refresh (create and reset) all the related indices
+ protected needRefreshIndices(checkpointReq?: IndexRequest): boolean {
+ return false;
+ }
+
+ protected ifCheckpointMet(req: IndexRequest, checkpointReq: IndexRequest): boolean {
+ // Please override this function
+ return false;
+ }
+
+ protected async cleanIndex(): Promise {
+ // This is the abstract implementation. You should override this.
+ return new Promise