From a162e646cc1dc4a5377bf03e352fed3362370ada Mon Sep 17 00:00:00 2001 From: Fuyao Zhao Date: Wed, 24 Apr 2019 10:10:38 -0700 Subject: [PATCH] Create a squash commit for code (#35547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Code] Use system JDK if it is available (#31389) * [Code] fix functional tests (#31555) * fix(code/frontend): main page (#29811) * [Code] fix find references style (#30911) * [Code]: properly reconnect when langserver is down and clean up logs, fix #839, #891 (#30601) * fix(code/frontend): wider clickable area for file and structure node (#31176) * fix(code/frontend): fix project filter press enter location change (#29919) * Code: move all non-error LSP logging to debug level * [Code] Dark mode cleanup (#31208) * fix(code/frontend): fix componentWillMount (#31772) * [Code] Remove socket.io and use polling message to pull progresses (#31398) * [Code] Remove socket.io and use polling message to pull progresses * [Code] refactor the status polling logic * [Code] fix a minor test issue * [Code] correctly handle url when workspace is a symbol link. (#31782) * fix(code/frontend): lose symbols (#31664) * [Code] functional test for code intelligence (#31673) * [Code] Add api test for multi code node setup (#31460) * [Code] fix the bug the first type in querybar is alwasy discarded (#31884) * [Code] disable cross repo jump functional test * fix(code/frontend): replace deprecated react lifecycle methods (#31874) * [Code] Add duration for queued tasks (#31885) * [Code] fix editor lifecycle method (#31983) * [Code] force delete repository (#31995) * feature(code/frontend): implement new breadcrumb design (#31247) * [Code] handle import error (#31875) * fix(code/frontend): show import project error message * [Code] increase the git clone/update throttle param to make ES data update less frequent (#31988) * fix(code/frontend): side navigation bar width should be fixed (#31876) * fix(code/frontend): should show import modal (#31987) * [Code] update repo by set target ref directly (#32002) * [Code] show nothing if setup status is not ready yet. (#31993) * [Code] fix editor `goto line` (#32094) * fix(code/frontend): match props missing (#32100) * [Code] Improve repository progress polling when clone/index is interrupted by delete (#31989) * [Code] fix tree flatten/expand/collapse problems (#32099) * [Code] Fix check for JDK's version (#32104) * fix(code/frontend): type errors (#32119) * [Code] specify nodegit commit sha in package.json (#32170) * [Code] fix setup page (#32179) * [Code] fix type error in java launcher (#32270) * [plugin installer] Keep external attributes of files during unarchiving (#32105) * [plugin installer] Keep external attributes of files during unarchiving * [plugin installer] add test for files' modes check * [Code] Ignore certificate check for clone (#32271) * [Code] fix tsc error * [Code] fix yarn.lock * Code: fix getClient is not a function error after merging (#32338) * [Code] fix load file tree by refresh (#32280) * [Code] Adjust `GoLauncher` to adapt to the running pattern of go server. (#32293) This adjust will let the code plugin connect the go-langserver actively. It should be noted that the `GoLauncher` is a semi-manufacture only support the detach mode for now. * [Code] Fix a line number bug for composite content calculation (#32376) * [Code] function test for securities (#32278) * [Code] hide import button if current user is not code_admin * [Code] function test for securities * [Code] apply the correct timestamp to admin page (#32379) * [Code]: fix duplicate import agains kbn/test/types package, update typescript language server version (#32439) * fix(code/frontend): highlight structure node (#32034) * fix(code/frontend): special symbol container name (#32281) * Monaco Editor Dark Mode (#32263) * Initial go at light/dark mode compatability for the Monaco editor. * Alphebetizing the imports. * Using the color-convert package to convert rgb to hex values. Updating the monaco hover widget for dark mode. * Changes to highlight and selection colors. * Misspelled an EUI color variable name. * Dark mode for the search results page. * Prettifying code_result.tsx. * Removing the monaco scroll decorator from the editor. * Fixing some type errors for color-convert * Markdown styling for dark mode. * Changing the import location of 'chrome' in the monaco editor * Adding a constant for the getTheme() method and adjusting blame view dark mode styles. * [Code]: always downgrade language server logging level by one * [Code]: upgrade ts langserver version, reduce the timeout for waiting langserver init * [Code] replace _term aggregator order to _key (#32541) * fix(code/frontend): reset breadcrumbs and fix code href (#32277) * [Code] improve index progress calculation (#32537) * [Code]: clean up the way we config LSP related configs (#32607) * [Code] change nodegit to @elastic/nodegit (#32543) * [Code] fix tree loading when jumping between different repos (#32650) * [Code] Use data dir as config dir (#32609) * [Code]: remove uneccessary color convert after upgrade to EUI 9 * [Code] fix a undefined path problem * [Code] compute url for language server plugin (#32644) * [Code] fix typo (#32751) [Code] fixed some type errors [Code] try change nodegit_info task to typescript to avoid ci problem * [Code]: Update UI test snapshot * [Code]: Disable project setting, branch selection and diff page (#32799) * [Code] adjust search bar suggestions style (#32726) * [Code] disable indent guides lines in editors (#32730) * feature(code/frontend): show loading spinner when loading file/structure tree (#32775) * fix(code/frontend): truncate blame date (#32764) * [Code] Call default to lsp options (#32843) * [Code] fix path handling in windows (#32882) * feature(code/frontend): implement new 404 page (#32859) * fix(code/frontend): truncate directory node and fix margin (#32858) * [Code]: Upgrade ts langserver version * [Code]: upgrade test snapshot * [Build]: use the resolution in CreatePackageJsonTask task * [Code]: correctly handle errors in JDK finding (#32824) * [Code] Fix functional test (#32991) * fix(code): clear import project input after submit (#32970) * [Code] Show some content for file name matching (#32958) * [Code] fix goto definition failed after user click `go back` (#32968) * Forcing the filetree open on mobile devices. (#33056) * [Code] remove requirement for SettingRepository button in tests (#33081) * [Code]: clean up the code breadcrumb (#33069) * [Code]: fix top bar button size, width and right margin (#33061) * [Code]: link to the setup guide button should be the entire button (#33031) * [Code] Remove the repository status in repo search result item (#32967) * [Code] Add additional git url validation in clone worker (#33097) * [Code] hide language server errors (#33082) * [Code] fix tree expand problems (#32984) * Fixes for dark mode (#33014) * Fixing the keyboard shortcut modal appearance in dark mode. * Fixing the language server icon colors in dark mode. * Fixing the white background in markdown code blocks in dark mode (Code issue #942) * Fixing the file tree background color (Code repo issue #986) * Updating file_tree snapshots. * Using variables in the shortcuts.scss file, moving the language icon selector to the path rather than the SVG itself. * Fixing a type error for an unused import. * [Code] make find references panel's file name clickable (#33083) * [Code] update worker queue index name to exclude from code user/admin roles (#33223) * fix(code): symbol tree style (#33224) * [Code] Index job timeout should show repository index error (#33140) * [Code] Index job timeout should show repository index error * [Code] Add a new unit test for clone worker git url validation * [Code] fix the test * [Code] adjust repo search scope REST API params (#33219) * [Code] scroll the selected file into view when navigate files (#33225) * [Code] fix a minor bug for clone repo * [Code] fix a tree expand/collapse problem (#33227) * [Code] fixes for search page (#33281) * [Code] fixes for search page * [Code] fix functional test * [Code] fix functioanl test * [Code] Calculate the index job timeout based on the size of the repo (#33226) * [Code]: add a test util file and move common class into it (#33283) * fix(code/frontend): unset min-width of breadcrumb (#33298) * fix(code): highlight only one symbol and unexpected tree loading (#33114) * [Code] use callWithRequest instead of callWithInternalUser in cluster routes (#33098) * [Code] Add options to disable maven/gradle importer and autobuild (#33240) * [Code] Add options to disable maven/gradle importer and autobuild * [Code] rename option to codeSecurity * [Code] Add initial options to request expander * [Code]: add option to disable node depdendency downloading (#33340) * [Code]: change config code.codeSecurity to code.security * [Code]: more clean up to the test option (#33355) * [Code] Add git clone url host and protocol whitelist (#33371) * [Code] align search page border and correct the rendering of empty search page (#33378) * [Code] focus input when switch search scope with shortcuts (#33379) * [Code] focus input when switch search scope with shortcuts * [Code] prevent default action of the shortcut event * fix(code): add project root link to kibana breadcrumb (#33297) * Revert "fix(code): add project root link to kibana breadcrumb (#33297)" This reverts commit e206b71171a22b87e1e0475765da828eed1c8128. * [Code]: upgrade to typescript server 0.1.19 * [Code] Fix randomized port in Java launcher (#33495) * [Code] fix popover style changes when click on buttons (#33472) * [Code] fix setup guide style (#33474) * [Code] fix a problem we start more than one lang-server for the same repo. (#33382) * fix(code): use monospace font for commit hash (#33307) * [Code] fix reference panel layout problems (#33546) * [Code] fix lang-sever initializing popover (#33482) * fix(code/frontend): truncate commit message (#33548) * fix(code/frontend): use eui toast for import message (#33487) * fix(code/frontend): check file path before reveal position (#33555) * fix(code/frontend): should have no container (#33492) * [Code] change lsp http error codes (#33633) * fix(code/frontend): combobox in search setting flyout should be stretched to fit the width (#33553) * [Code] Enabgle `go` language of the monaco editor. (#33476) This change will make the code plugin have the ability in the development mode to highlight the go source code and send the go-to definition request to the lang server. * fix(code/frontend): error message for empty project url (#33549) * [Code] connect the modify search settings button with the search scope settings (#33691) * [Code] connect the modify search settings button with the search scope setting * [Code] a minor fix * [Code] minor style improvement * [Code]: fix integration test using new API (#33730) * [Code] fix a tree expanding problem (#33766) * Fixing the directory node focus state. (#33821) * [Code] fix a reference panel height problem (#33767) * [Code] upgrade nodegit, set max returned commits results (#33913) * [Code]: Upgrade dependencies * [Code]: Syntax clean after bable-ts-transform upgrade * [Code] don't patch native modules when build oss package (#33915) * [Code] improve repository index naming (#33911) * Revert "Fixing the directory node focus state. (#33821)" This reverts commit 866db39ec3f354535e238c09a253361d102da2e5. * [Code] Remove regex git url validation and add more unit tests for repository utils (#33919) * [Code] more unit tests * [Code] fix ci breaks * [Code] handle nodegit deprecation warnings (#33932) * Reducing top and bottom padding of the directory and file nodes in the directory view. (#34007) * Styling the File Tree Scrollbar (#33988) * Styling the scrollbar. * Removing the duplicated mixin code. * [Code] check file path in lsp requests (#33916) * [Code] Implement the index checkpointing (#32682) * [Code] Persist index checkpoint into index progress in ES * [Code] apply checkpoint to lsp indexer * [Code] Add unit tests for index checkpointing * [Code] move checkpoint from text to object * [Code]: raise default security level (#33956) * fix(code/frontend): should not show import error at the first time (#33921) * [Code]: add missing dependencies that are not in x-pack * [Code]: fix test snapshot and eui usages * chore(code/frontend): move props files to __fixtures__ folder (#34031) * [Code] fix a tree collapse problem (#34030) * [Code] fix a tree collapse problem added functional tests for file tree * Fix type errors and snapshot * [Code]: simplify the path computation of ts server * [Code]: clean uneccessary ts-ignores (#34203) * [Code] apply repo search scope right away in search page (#34029) * [Code] upgrade git-url-parse version and enable ssh git clone protocol (#34336) * [Code] upgrade git-url-parse version and enable ssh git clone protocol * [Code] fix unit test * [Code]: minor clean up of tslint usage, up ts server version * fix(code/frontend): use different actions to handle repo scope search and repo search (#34043) * [Code] fix the crash when we refresh the blame view (#34335) * [Code] enable ssh protocol, only read ssh key pairs from data folder. (#34412) * [Code] reset processing jobs when system is initializing (#34408) * [Code] functional test for git:// url (#34512) * [Code] fix search query bar item selection issue (#34514) * [Code]: use absolute path for api path (#34582) * [Code]: use absolute path for api path * [Code]: always use url.format to construct url with queries that have variables * [Code]: prefix lsp api with code * [Code]: Add chrome.addBasePath call for raw fetch argument * [Code]: minor UI adjustment (#34659) * [Code]: fix new eslint errors (#34671) * [Code] save code_node_info in an index (#34244) * [Code] fix line height changed after find reference is open (#34682) * [Code] add description for file and repo typeahead items (#34681) * [Code] encode/decode branchs and tags (#34683) * [Code] Incremental Indexing (#33485) * [Code] Add a git api to get diff from arbitrary 2 revisions * [Code] Apply incremental index triggering * [Code] implement the actual incremental indexing * [Code] apply checkpoint validation for both lsp and lsp incremental indexer * [Code] add unit tests * [Code] only disable index scheduler but leave update scheduler on * [Code]: disable more eslint error due to nodegit * [Code]: add a go language icon * [Code]: fix test snapshot after upgrade eui * fix(code/frontend): make blame view scrollable (#34519) * [Code]: add beta indicator (#34899) * [Code]: add a toast for permission change in setup page (#34901) * [Code] support '/' in getCommit (#34774) * [Code]: show error message when importing repo at import repo page, fix type error (#34898) * [Code]: add setup guide link in help menu and pre-define document links (#34902) * [Code] hide index button when repo is in indexing (#34904) * [Code]: clean uneccessary code, lint error * [Code] apply encode to revision in url (#34906) * [Code] disable blame button when select a non-text file (#34775) * [Code] disable blame button when select a non-text file * [Code] Change button label based on file type * [Code] Provide more reasons for git url validation (#34914) * fix(code/frontend): should disable structure tab if no structure or load failed (#34908) * [Code] don't allow access secured routes before x-pack info is available (#34994) * fix(code/frontend): remove line decorations if no line number specified (#35047) * Reset initialized when proxy re-connects (#34970) * [Code] setup multi-code mode by config (#34988) * [Code] Persist clone error messages (#34977) * test(code/frontend): history functional test (#34921) * Implement Code feature control (#35115) * update security api tests * rough POC to migrate Code to use Feature Controls * fix tests * [Code]: Integrate with Feature control * Rename callWithRequest to callCluster * feature(code/frontend): search filter default repo options (#35202) * [Code] minor fix of proejct status update (#35207) * feature(code/frontend): search in project page set current repo as default scope (#35062) * fix(code/frontend): structure tab highlight, align and collapse (#35221) * [Code] add api integration tests for feature control (#35146) * [Code] Ignore certificateCheck in update as well (#35273) * [Code]: fix test snapshot * [Code] Removing styled components & SCSS cleanup (#35107) * Removing the sidebar class from the project container and replacing styled component eui buttons with a className. * Renaming scss includes. * Moving admin.scss content into _buttons.scss. * Refactor project_item removing styled components * Refactor admin.tsx to remove styled components * Refactor import_project.tsx to remove styled components * Refactor lang server tab to remove styled components * Refactor project settings modal removing styled components * Refactoring setup_guide to remove styled components * Cleanup sidebar.scss: follow convention for classes * Refactor codeblock css naming conventions * Resolving an issue with the monaco scss file name * Editor file cleanup. Renaming css classes * Cleaning up the file_tree component. * Hover widget cleanup. * Blame cleanup. * Breadcrumb cleanup. * Cleaning up clone status — removing ProgressContainer export. Didn't seem to be used anywhere. You can use the codeContainer__progress class to apply those styles now. * Cleaning up commit history styles * Putting the indentation back in the file tree. * Refactoring the main content window. * Cleaning up the directory component. * Reducing spacing between directory and file lists. * Removing styled components from the error panel. * Reducing the font size of buttons in the source view button groups. * Removing styled components from main.tsx * CSS naming & removing styled components from not_found.tsx * CSS naming & removing styled components from not_found.tsx * Removing styled components from search_bar and top_bar. * Removing styled components from query_bar components. * Removing styled components from search_page components. * removing styled components from code_symbol_tree * Fixing a few css issues. * Updating test snapshots. * Removing a stray '>' symbol from the search tabs. * Condensing the spacing of the EUI facets on the search page. * class name of the flyout container. * Revert "class name of the flyout container." This reverts commit 35e9d5c16fd20db5ef15a686eda79bb0fd3f40a6. * class name tweaks. * Fixing type errors. * Putting back an accidental deletion in file_tree.tsx. * Updating file_tree snapshot. * Implementing changes from 604e4d117303b548c8ab039a2f83cdf8043b6b21 to address failing tests. * Adding in additional classes deleted during merge * Updating test snapshots. * Removing the focusring from the items in the file_tree. (#35364) * [Code] pagination for history (#35329) * Updating the markdown rendering to use EUI styles. (#35439) * [Code]: fix icon for module and namespace (#35428) * [Code] apply git clone/update cert check for production env (#35399) * fix(code/frontend): source view page, click line number should stay in the same side tab (#35396) * [Code] Add a security flag for git certificate check (#35445) * fix(code/frontend): fix blank left to blame (#35449) * [Code]: Improve code setup guide text --- package.json | 12 +- packages/kbn-ui-framework/package.json | 2 +- src/cli/cluster/cluster_manager.js | 1 + .../__fixtures__/replies/test_plugin.zip | Bin 2314 -> 2988 bytes src/cli_plugin/install/zip.js | 2 +- src/cli_plugin/install/zip.test.js | 23 + src/dev/build/build_distributables.js | 2 + src/dev/build/tasks/index.js | 1 + .../build/tasks/patch_native_modules_task.js | 91 ++ .../ui/public/chrome/api/breadcrumbs.ts | 7 + x-pack/index.js | 2 + x-pack/package.json | 43 +- x-pack/plugins/code/common/git_blame.ts | 19 + x-pack/plugins/code/common/git_diff.ts | 35 + .../plugins/code/common/git_url_utils.test.ts | 55 + x-pack/plugins/code/common/git_url_utils.ts | 32 + x-pack/plugins/code/common/installation.ts | 26 + x-pack/plugins/code/common/language_server.ts | 25 + x-pack/plugins/code/common/line_mapper.ts | 37 + x-pack/plugins/code/common/lsp_client.ts | 44 + x-pack/plugins/code/common/lsp_error_codes.ts | 13 + x-pack/plugins/code/common/lsp_method.ts | 39 + .../code/common/repository_utils.test.ts | 206 +++ .../plugins/code/common/repository_utils.ts | 114 ++ .../code/common/text_document_methods.ts | 36 + x-pack/plugins/code/common/uri_util.test.ts | 86 + x-pack/plugins/code/common/uri_util.ts | 131 ++ x-pack/plugins/code/index.ts | 76 + x-pack/plugins/code/model/commit.ts | 27 + x-pack/plugins/code/model/highlight.ts | 19 + x-pack/plugins/code/model/index.ts | 13 + x-pack/plugins/code/model/lsp.ts | 16 + x-pack/plugins/code/model/repository.ts | 149 ++ x-pack/plugins/code/model/search.ts | 155 ++ x-pack/plugins/code/model/socket.ts | 12 + x-pack/plugins/code/model/task.ts | 25 + x-pack/plugins/code/model/test_config.ts | 21 + x-pack/plugins/code/model/workspace.ts | 15 + x-pack/plugins/code/public/actions/blame.ts | 18 + x-pack/plugins/code/public/actions/commit.ts | 12 + x-pack/plugins/code/public/actions/editor.ts | 37 + x-pack/plugins/code/public/actions/file.ts | 77 + x-pack/plugins/code/public/actions/index.ts | 30 + .../code/public/actions/language_server.ts | 21 + .../code/public/actions/project_config.ts | 14 + .../code/public/actions/recent_projects.ts | 11 + .../plugins/code/public/actions/repository.ts | 46 + x-pack/plugins/code/public/actions/search.ts | 52 + .../plugins/code/public/actions/shortcuts.ts | 13 + x-pack/plugins/code/public/actions/status.ts | 24 + .../plugins/code/public/actions/structure.ts | 20 + x-pack/plugins/code/public/app.tsx | 60 + x-pack/plugins/code/public/common/types.ts | 49 + .../public/components/admin_page/admin.tsx | 132 ++ .../components/admin_page/empty_project.tsx | 35 + .../components/admin_page/import_project.tsx | 124 ++ .../admin_page/language_server_tab.tsx | 240 +++ .../components/admin_page/project_item.tsx | 238 +++ .../admin_page/project_settings.tsx | 150 ++ .../components/admin_page/project_tab.tsx | 286 ++++ .../components/admin_page/setup_guide.tsx | 169 ++ x-pack/plugins/code/public/components/app.tsx | 52 + .../public/components/codeblock/codeblock.tsx | 139 ++ .../components/diff_page/commit_link.tsx | 23 + .../public/components/diff_page/diff.scss | 16 + .../code/public/components/diff_page/diff.tsx | 246 +++ .../components/diff_page/diff_editor.tsx | 44 + .../code/public/components/editor/editor.tsx | 236 +++ .../components/editor/references_panel.scss | 33 + .../components/editor/references_panel.tsx | 163 ++ .../file_tree/__fixtures__/props.json | 203 +++ .../__snapshots__/file_tree.test.tsx.snap | 1443 +++++++++++++++++ .../components/file_tree/file_tree.test.tsx | 52 + .../public/components/file_tree/file_tree.tsx | 270 +++ .../public/components/help_menu/help_menu.tsx | 32 + .../code/public/components/help_menu/index.ts | 7 + .../public/components/hover/hover_buttons.tsx | 46 + .../public/components/hover/hover_widget.tsx | 101 ++ .../code/public/components/main/blame.tsx | 48 + .../public/components/main/breadcrumb.tsx | 35 + .../public/components/main/clone_status.tsx | 64 + .../public/components/main/commit_history.tsx | 152 ++ .../code/public/components/main/content.tsx | 395 +++++ .../code/public/components/main/directory.tsx | 87 + .../public/components/main/error_panel.tsx | 38 + .../code/public/components/main/main.scss | 281 ++++ .../code/public/components/main/main.tsx | 70 + .../code/public/components/main/not_found.tsx | 18 + .../public/components/main/search_bar.tsx | 127 ++ .../code/public/components/main/side_tabs.tsx | 119 ++ .../code/public/components/main/top_bar.tsx | 72 + .../components/__fixtures__/props.json | 55 + .../__snapshots__/query_bar.test.tsx.snap | 1437 ++++++++++++++++ .../components/query_bar/components/index.ts | 7 + .../query_bar/components/options.tsx | 226 +++ .../query_bar/components/query_bar.test.tsx | 131 ++ .../query_bar/components/query_bar.tsx | 515 ++++++ .../query_bar/components/scope_selector.tsx | 72 + .../suggestion_component.test.tsx.snap | 201 +++ .../suggestions_component.test.tsx.snap | 639 ++++++++ .../typeahead/suggestion_component.test.tsx | 70 + .../typeahead/suggestion_component.tsx | 80 + .../typeahead/suggestions_component.test.tsx | 53 + .../typeahead/suggestions_component.tsx | 200 +++ .../code/public/components/query_bar/index.ts | 17 + .../components/query_bar/lib/match_pairs.ts | 133 ++ .../suggestions/file_suggestions_provider.ts | 63 + .../components/query_bar/suggestions/index.ts | 32 + .../repository_suggestions_provider.ts | 63 + .../suggestions/suggestions_provider.ts | 59 + .../symbol_suggestions_provider.ts | 144 ++ .../plugins/code/public/components/route.ts | 35 + .../plugins/code/public/components/routes.ts | 19 + .../components/search_page/code_result.tsx | 91 ++ .../search_page/empty_placeholder.tsx | 44 + .../components/search_page/pagination.tsx | 49 + .../components/search_page/scope_tabs.tsx | 60 + .../public/components/search_page/search.tsx | 240 +++ .../components/search_page/search_bar.tsx | 119 ++ .../components/search_page/side_bar.tsx | 150 ++ .../code/public/components/shared/icons.tsx | 333 ++++ .../public/components/shortcuts/index.tsx | 8 + .../public/components/shortcuts/shortcut.tsx | 84 + .../shortcuts/shortcuts_provider.tsx | 203 +++ .../__test__/__fixtures__/props.ts | 140 ++ .../__snapshots__/symbol_tree.test.tsx.snap | 712 ++++++++ .../symbol_tree/__test__/symbol_tree.test.tsx | 28 + .../symbol_tree/code_symbol_tree.tsx | 145 ++ .../components/symbol_tree/symbol_tree.tsx | 26 + x-pack/plugins/code/public/index.scss | 19 + .../code/public/lib/documentation_links.ts | 14 + .../code/public/monaco/blame/blame_widget.ts | 73 + x-pack/plugins/code/public/monaco/computer.ts | 14 + .../code/public/monaco/content_widget.ts | 140 ++ .../monaco/definition/definition_provider.ts | 68 + .../plugins/code/public/monaco/disposable.ts | 25 + .../code/public/monaco/editor_service.ts | 75 + .../monaco/hover/content_hover_widget.ts | 208 +++ .../public/monaco/hover/hover_computer.ts | 55 + .../public/monaco/hover/hover_controller.ts | 64 + .../code/public/monaco/immortal_reference.ts | 14 + x-pack/plugins/code/public/monaco/monaco.ts | 156 ++ .../code/public/monaco/monaco_diff_editor.ts | 42 + .../code/public/monaco/monaco_helper.ts | 166 ++ .../plugins/code/public/monaco/operation.ts | 90 + .../public/monaco/override_monaco_styles.scss | 12 + .../monaco/references/references_action.ts | 30 + .../public/monaco/single_selection_helper.ts | 33 + .../code/public/monaco/textmodel_resolver.ts | 72 + x-pack/plugins/code/public/reducers/blame.ts | 42 + x-pack/plugins/code/public/reducers/commit.ts | 41 + x-pack/plugins/code/public/reducers/editor.ts | 77 + x-pack/plugins/code/public/reducers/file.ts | 233 +++ x-pack/plugins/code/public/reducers/index.ts | 55 + .../code/public/reducers/language_server.ts | 61 + .../code/public/reducers/repository.ts | 129 ++ x-pack/plugins/code/public/reducers/route.ts | 27 + x-pack/plugins/code/public/reducers/search.ts | 184 +++ x-pack/plugins/code/public/reducers/setup.ts | 30 + .../plugins/code/public/reducers/shortcuts.ts | 57 + x-pack/plugins/code/public/reducers/status.ts | 184 +++ x-pack/plugins/code/public/reducers/symbol.ts | 162 ++ x-pack/plugins/code/public/sagas/blame.ts | 42 + x-pack/plugins/code/public/sagas/commit.ts | 32 + x-pack/plugins/code/public/sagas/editor.ts | 213 +++ x-pack/plugins/code/public/sagas/file.ts | 267 +++ x-pack/plugins/code/public/sagas/index.ts | 84 + .../code/public/sagas/language_server.ts | 56 + x-pack/plugins/code/public/sagas/patterns.ts | 27 + .../code/public/sagas/project_config.ts | 68 + .../code/public/sagas/project_status.ts | 274 ++++ .../plugins/code/public/sagas/repository.ts | 173 ++ x-pack/plugins/code/public/sagas/search.ts | 141 ++ x-pack/plugins/code/public/sagas/setup.ts | 28 + x-pack/plugins/code/public/sagas/status.ts | 47 + x-pack/plugins/code/public/sagas/structure.ts | 32 + x-pack/plugins/code/public/selectors/index.ts | 91 ++ x-pack/plugins/code/public/stores/index.ts | 18 + .../plugins/code/public/style/_buttons.scss | 27 + .../plugins/code/public/style/_filetree.scss | 57 + .../plugins/code/public/style/_filters.scss | 8 + x-pack/plugins/code/public/style/_layout.scss | 179 ++ .../plugins/code/public/style/_markdown.scss | 11 + x-pack/plugins/code/public/style/_monaco.scss | 109 ++ .../plugins/code/public/style/_query_bar.scss | 6 + .../plugins/code/public/style/_shortcuts.scss | 23 + .../plugins/code/public/style/_sidebar.scss | 95 ++ .../plugins/code/public/style/_utilities.scss | 11 + x-pack/plugins/code/public/style/variables.ts | 35 + .../plugins/code/public/utils/test_utils.ts | 64 + x-pack/plugins/code/public/utils/url.ts | 19 + .../code/server/__tests__/clone_worker.ts | 237 +++ .../code/server/__tests__/git_operations.ts | 135 ++ .../__tests__/lsp_incremental_indexer.ts | 303 ++++ .../code/server/__tests__/lsp_indexer.ts | 303 ++++ .../code/server/__tests__/lsp_service.ts | 203 +++ .../code/server/__tests__/multi_node.ts | 111 ++ .../server/__tests__/repository_service.ts | 61 + .../server/__tests__/workspace_handler.ts | 181 +++ x-pack/plugins/code/server/check_repos.ts | 38 + x-pack/plugins/code/server/git_operations.ts | 563 +++++++ .../code/server/indexer/abstract_indexer.ts | 189 +++ .../server/indexer/batch_index_helper.test.ts | 71 + .../code/server/indexer/batch_index_helper.ts | 71 + x-pack/plugins/code/server/indexer/index.ts | 16 + .../server/indexer/index_creation_request.ts | 58 + .../code/server/indexer/index_creator.test.ts | 70 + .../code/server/indexer/index_creator.ts | 64 + .../server/indexer/index_migrator.test.ts | 57 + .../code/server/indexer/index_migrator.ts | 146 ++ .../indexer/index_version_controller.test.ts | 111 ++ .../indexer/index_version_controller.ts | 63 + x-pack/plugins/code/server/indexer/indexer.ts | 18 + .../server/indexer/lsp_incremental_indexer.ts | 285 ++++ .../code/server/indexer/lsp_indexer.ts | 242 +++ .../server/indexer/lsp_indexer_factory.ts | 61 + .../repository_index_initializer.test.ts | 52 + .../indexer/repository_index_initializer.ts | 47 + .../repository_index_initializer_factory.ts | 18 + .../code/server/indexer/schema/document.ts | 255 +++ .../code/server/indexer/schema/index.ts | 10 + .../code/server/indexer/schema/reference.ts | 43 + .../code/server/indexer/schema/repository.ts | 41 + .../code/server/indexer/schema/symbol.ts | 80 + .../code/server/indexer/schema/version.json | 3 + x-pack/plugins/code/server/init.ts | 255 +++ .../lib/esqueue/constants/default_settings.js | 15 + .../server/lib/esqueue/constants/events.d.ts | 25 + .../server/lib/esqueue/constants/events.js | 21 + .../server/lib/esqueue/constants/index.js | 15 + .../lib/esqueue/constants/statuses.d.ts | 7 + .../server/lib/esqueue/constants/statuses.js | 13 + .../code/server/lib/esqueue/esqueue.d.ts | 63 + .../code/server/lib/esqueue/esqueue.js | 82 + .../esqueue/helpers/cancellation_token.d.ts | 10 + .../lib/esqueue/helpers/cancellation_token.js | 30 + .../lib/esqueue/helpers/create_index.js | 90 + .../code/server/lib/esqueue/helpers/errors.js | 26 + .../lib/esqueue/helpers/index_timestamp.js | 46 + .../code/server/lib/esqueue/helpers/poller.js | 81 + .../code/server/lib/esqueue/index.d.ts | 12 + .../plugins/code/server/lib/esqueue/index.js | 9 + .../plugins/code/server/lib/esqueue/job.d.ts | 102 ++ x-pack/plugins/code/server/lib/esqueue/job.js | 134 ++ .../plugins/code/server/lib/esqueue/misc.d.ts | 38 + .../code/server/lib/esqueue/worker.d.ts | 98 ++ .../plugins/code/server/lib/esqueue/worker.js | 480 ++++++ .../plugins/code/server/lib/esqueue/yarn.lock | 13 + x-pack/plugins/code/server/log.ts | 87 + .../code/server/lsp/controller.test.ts | 148 ++ x-pack/plugins/code/server/lsp/controller.ts | 263 +++ x-pack/plugins/code/server/lsp/go_launcher.ts | 46 + .../code/server/lsp/http_message_reader.ts | 28 + .../code/server/lsp/http_message_writer.ts | 48 + .../code/server/lsp/http_request_emitter.ts | 9 + .../code/server/lsp/install_manager.test.ts | 129 ++ .../code/server/lsp/install_manager.ts | 207 +++ .../plugins/code/server/lsp/java_launcher.ts | 255 +++ .../lsp/language_server_launcher.test.ts | 41 + .../server/lsp/language_server_launcher.ts | 24 + .../code/server/lsp/language_servers.ts | 51 + .../plugins/code/server/lsp/lsp_benchmark.ts | 81 + x-pack/plugins/code/server/lsp/lsp_service.ts | 86 + .../code/server/lsp/lsp_test_runner.ts | 252 +++ x-pack/plugins/code/server/lsp/proxy.ts | 308 ++++ x-pack/plugins/code/server/lsp/replies_map.ts | 16 + .../code/server/lsp/request_expander.test.ts | 195 +++ .../code/server/lsp/request_expander.ts | 291 ++++ .../plugins/code/server/lsp/test_config.yml | 7 + .../code/server/lsp/test_repo_manager.ts | 64 + x-pack/plugins/code/server/lsp/ts_launcher.ts | 112 ++ .../code/server/lsp/workspace_command.ts | 106 ++ .../code/server/lsp/workspace_handler.test.ts | 122 ++ .../code/server/lsp/workspace_handler.ts | 422 +++++ x-pack/plugins/code/server/poller.ts | 103 ++ .../code/server/queue/abstract_git_worker.ts | 93 ++ .../code/server/queue/abstract_worker.ts | 152 ++ .../server/queue/cancellation_service.test.ts | 32 + .../code/server/queue/cancellation_service.ts | 33 + .../plugins/code/server/queue/clone_worker.ts | 111 ++ .../code/server/queue/delete_worker.test.ts | 159 ++ .../code/server/queue/delete_worker.ts | 147 ++ x-pack/plugins/code/server/queue/index.ts | 12 + .../code/server/queue/index_worker.test.ts | 389 +++++ .../plugins/code/server/queue/index_worker.ts | 202 +++ x-pack/plugins/code/server/queue/job.ts | 14 + .../code/server/queue/update_worker.test.ts | 62 + .../code/server/queue/update_worker.ts | 44 + x-pack/plugins/code/server/queue/worker.ts | 20 + .../server/repository_config_controller.ts | 47 + .../plugins/code/server/repository_service.ts | 263 +++ .../code/server/repository_service_factory.ts | 19 + x-pack/plugins/code/server/routes/file.ts | 240 +++ x-pack/plugins/code/server/routes/install.ts | 74 + x-pack/plugins/code/server/routes/lsp.ts | 187 +++ x-pack/plugins/code/server/routes/redirect.ts | 39 + .../plugins/code/server/routes/repository.ts | 287 ++++ x-pack/plugins/code/server/routes/search.ts | 180 ++ x-pack/plugins/code/server/routes/setup.ts | 18 + .../plugins/code/server/routes/workspace.ts | 68 + .../server/scheduler/abstract_scheduler.ts | 82 + x-pack/plugins/code/server/scheduler/index.ts | 8 + .../server/scheduler/index_scheduler.test.ts | 353 ++++ .../code/server/scheduler/index_scheduler.ts | 87 + .../server/scheduler/update_scheduler.test.ts | 229 +++ .../code/server/scheduler/update_scheduler.ts | 81 + .../server/search/abstract_search_client.ts | 39 + .../search/document_search_client.test.ts | 230 +++ .../server/search/document_search_client.ts | 390 +++++ x-pack/plugins/code/server/search/index.ts | 10 + .../search/repository_object_client.test.ts | 318 ++++ .../server/search/repository_object_client.ts | 168 ++ .../search/repository_search_client.test.ts | 133 ++ .../server/search/repository_search_client.ts | 87 + .../code/server/search/search_client.ts | 11 + .../search/symbol_search_client.test.ts | 168 ++ .../server/search/symbol_search_client.ts | 149 ++ x-pack/plugins/code/server/security.ts | 30 + x-pack/plugins/code/server/server_options.ts | 69 + x-pack/plugins/code/server/test_utils.ts | 54 + .../plugins/code/server/utils/buffer.test.ts | 35 + x-pack/plugins/code/server/utils/buffer.ts | 34 + .../utils/composite_source_merger.test.ts | 43 + .../server/utils/composite_source_merger.ts | 133 ++ .../code/server/utils/console_logger.ts | 43 + .../server/utils/console_logger_factory.ts | 15 + .../code/server/utils/detect_language.ts | 56 + .../code/server/utils/es_index_client.ts | 52 + .../server/utils/esclient_with_request.ts | 54 + .../plugins/code/server/utils/extensions.json | 846 ++++++++++ .../server/utils/index_stats_aggregator.ts | 20 + .../plugins/code/server/utils/log_factory.ts | 11 + .../server/utils/server_logger_factory.ts | 17 + x-pack/plugins/code/server/utils/timeout.ts | 27 + .../plugins/code/server/utils/with_request.ts | 27 + x-pack/plugins/code/tasks/nodegit_info.ts | 20 + .../plugins/code/webpackShims/stats-lite.d.ts | 19 + x-pack/tasks/test.js | 3 +- .../apis/code/feature_controls.ts | 365 +++++ .../test/api_integration/apis/code/index.ts | 16 + x-pack/test/api_integration/apis/index.js | 1 + .../apis/security/privileges.ts | 58 + .../apis/xpack_main/features/features.ts | 1 + .../functional/apps/code/code_intelligence.ts | 259 +++ .../apps/code/explore_repository.ts | 210 +++ x-pack/test/functional/apps/code/history.ts | 145 ++ x-pack/test/functional/apps/code/index.ts | 19 + x-pack/test/functional/apps/code/lib/types.ts | 27 + .../apps/code/manage_repositories.ts | 110 ++ x-pack/test/functional/apps/code/search.ts | 97 ++ .../functional/apps/code/with_security.ts | 148 ++ x-pack/test/functional/config.js | 12 + .../functional/es_archives/code/data.json | 199 +++ .../functional/es_archives/code/mappings.json | 243 +++ .../test/functional/page_objects/code_page.ts | 42 + x-pack/test/functional/page_objects/index.js | 1 + yarn.lock | 673 ++++++-- 357 files changed, 38591 insertions(+), 104 deletions(-) create mode 100644 src/dev/build/tasks/patch_native_modules_task.js create mode 100644 x-pack/plugins/code/common/git_blame.ts create mode 100644 x-pack/plugins/code/common/git_diff.ts create mode 100644 x-pack/plugins/code/common/git_url_utils.test.ts create mode 100644 x-pack/plugins/code/common/git_url_utils.ts create mode 100644 x-pack/plugins/code/common/installation.ts create mode 100644 x-pack/plugins/code/common/language_server.ts create mode 100644 x-pack/plugins/code/common/line_mapper.ts create mode 100644 x-pack/plugins/code/common/lsp_client.ts create mode 100644 x-pack/plugins/code/common/lsp_error_codes.ts create mode 100644 x-pack/plugins/code/common/lsp_method.ts create mode 100644 x-pack/plugins/code/common/repository_utils.test.ts create mode 100644 x-pack/plugins/code/common/repository_utils.ts create mode 100644 x-pack/plugins/code/common/text_document_methods.ts create mode 100644 x-pack/plugins/code/common/uri_util.test.ts create mode 100644 x-pack/plugins/code/common/uri_util.ts create mode 100644 x-pack/plugins/code/index.ts create mode 100644 x-pack/plugins/code/model/commit.ts create mode 100644 x-pack/plugins/code/model/highlight.ts create mode 100644 x-pack/plugins/code/model/index.ts create mode 100644 x-pack/plugins/code/model/lsp.ts create mode 100644 x-pack/plugins/code/model/repository.ts create mode 100644 x-pack/plugins/code/model/search.ts create mode 100644 x-pack/plugins/code/model/socket.ts create mode 100644 x-pack/plugins/code/model/task.ts create mode 100644 x-pack/plugins/code/model/test_config.ts create mode 100644 x-pack/plugins/code/model/workspace.ts create mode 100644 x-pack/plugins/code/public/actions/blame.ts create mode 100644 x-pack/plugins/code/public/actions/commit.ts create mode 100644 x-pack/plugins/code/public/actions/editor.ts create mode 100644 x-pack/plugins/code/public/actions/file.ts create mode 100644 x-pack/plugins/code/public/actions/index.ts create mode 100644 x-pack/plugins/code/public/actions/language_server.ts create mode 100644 x-pack/plugins/code/public/actions/project_config.ts create mode 100644 x-pack/plugins/code/public/actions/recent_projects.ts create mode 100644 x-pack/plugins/code/public/actions/repository.ts create mode 100644 x-pack/plugins/code/public/actions/search.ts create mode 100644 x-pack/plugins/code/public/actions/shortcuts.ts create mode 100644 x-pack/plugins/code/public/actions/status.ts create mode 100644 x-pack/plugins/code/public/actions/structure.ts create mode 100644 x-pack/plugins/code/public/app.tsx create mode 100644 x-pack/plugins/code/public/common/types.ts create mode 100644 x-pack/plugins/code/public/components/admin_page/admin.tsx create mode 100644 x-pack/plugins/code/public/components/admin_page/empty_project.tsx create mode 100644 x-pack/plugins/code/public/components/admin_page/import_project.tsx create mode 100644 x-pack/plugins/code/public/components/admin_page/language_server_tab.tsx create mode 100644 x-pack/plugins/code/public/components/admin_page/project_item.tsx create mode 100644 x-pack/plugins/code/public/components/admin_page/project_settings.tsx create mode 100644 x-pack/plugins/code/public/components/admin_page/project_tab.tsx create mode 100644 x-pack/plugins/code/public/components/admin_page/setup_guide.tsx create mode 100644 x-pack/plugins/code/public/components/app.tsx create mode 100644 x-pack/plugins/code/public/components/codeblock/codeblock.tsx create mode 100644 x-pack/plugins/code/public/components/diff_page/commit_link.tsx create mode 100644 x-pack/plugins/code/public/components/diff_page/diff.scss create mode 100644 x-pack/plugins/code/public/components/diff_page/diff.tsx create mode 100644 x-pack/plugins/code/public/components/diff_page/diff_editor.tsx create mode 100644 x-pack/plugins/code/public/components/editor/editor.tsx create mode 100644 x-pack/plugins/code/public/components/editor/references_panel.scss create mode 100644 x-pack/plugins/code/public/components/editor/references_panel.tsx create mode 100644 x-pack/plugins/code/public/components/file_tree/__fixtures__/props.json create mode 100644 x-pack/plugins/code/public/components/file_tree/__snapshots__/file_tree.test.tsx.snap create mode 100644 x-pack/plugins/code/public/components/file_tree/file_tree.test.tsx create mode 100644 x-pack/plugins/code/public/components/file_tree/file_tree.tsx create mode 100644 x-pack/plugins/code/public/components/help_menu/help_menu.tsx create mode 100644 x-pack/plugins/code/public/components/help_menu/index.ts create mode 100644 x-pack/plugins/code/public/components/hover/hover_buttons.tsx create mode 100644 x-pack/plugins/code/public/components/hover/hover_widget.tsx create mode 100644 x-pack/plugins/code/public/components/main/blame.tsx create mode 100644 x-pack/plugins/code/public/components/main/breadcrumb.tsx create mode 100644 x-pack/plugins/code/public/components/main/clone_status.tsx create mode 100644 x-pack/plugins/code/public/components/main/commit_history.tsx create mode 100644 x-pack/plugins/code/public/components/main/content.tsx create mode 100644 x-pack/plugins/code/public/components/main/directory.tsx create mode 100644 x-pack/plugins/code/public/components/main/error_panel.tsx create mode 100644 x-pack/plugins/code/public/components/main/main.scss create mode 100644 x-pack/plugins/code/public/components/main/main.tsx create mode 100644 x-pack/plugins/code/public/components/main/not_found.tsx create mode 100644 x-pack/plugins/code/public/components/main/search_bar.tsx create mode 100644 x-pack/plugins/code/public/components/main/side_tabs.tsx create mode 100644 x-pack/plugins/code/public/components/main/top_bar.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/__fixtures__/props.json create mode 100644 x-pack/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap create mode 100644 x-pack/plugins/code/public/components/query_bar/components/index.ts create mode 100644 x-pack/plugins/code/public/components/query_bar/components/options.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/query_bar.test.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/query_bar.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/scope_selector.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestion_component.test.tsx.snap create mode 100644 x-pack/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap create mode 100644 x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.test.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestion_component.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.test.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx create mode 100644 x-pack/plugins/code/public/components/query_bar/index.ts create mode 100644 x-pack/plugins/code/public/components/query_bar/lib/match_pairs.ts create mode 100644 x-pack/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts create mode 100644 x-pack/plugins/code/public/components/query_bar/suggestions/index.ts create mode 100644 x-pack/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts create mode 100644 x-pack/plugins/code/public/components/query_bar/suggestions/suggestions_provider.ts create mode 100644 x-pack/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts create mode 100644 x-pack/plugins/code/public/components/route.ts create mode 100644 x-pack/plugins/code/public/components/routes.ts create mode 100644 x-pack/plugins/code/public/components/search_page/code_result.tsx create mode 100644 x-pack/plugins/code/public/components/search_page/empty_placeholder.tsx create mode 100644 x-pack/plugins/code/public/components/search_page/pagination.tsx create mode 100644 x-pack/plugins/code/public/components/search_page/scope_tabs.tsx create mode 100644 x-pack/plugins/code/public/components/search_page/search.tsx create mode 100644 x-pack/plugins/code/public/components/search_page/search_bar.tsx create mode 100644 x-pack/plugins/code/public/components/search_page/side_bar.tsx create mode 100644 x-pack/plugins/code/public/components/shared/icons.tsx create mode 100644 x-pack/plugins/code/public/components/shortcuts/index.tsx create mode 100644 x-pack/plugins/code/public/components/shortcuts/shortcut.tsx create mode 100644 x-pack/plugins/code/public/components/shortcuts/shortcuts_provider.tsx create mode 100644 x-pack/plugins/code/public/components/symbol_tree/__test__/__fixtures__/props.ts create mode 100644 x-pack/plugins/code/public/components/symbol_tree/__test__/__snapshots__/symbol_tree.test.tsx.snap create mode 100644 x-pack/plugins/code/public/components/symbol_tree/__test__/symbol_tree.test.tsx create mode 100644 x-pack/plugins/code/public/components/symbol_tree/code_symbol_tree.tsx create mode 100644 x-pack/plugins/code/public/components/symbol_tree/symbol_tree.tsx create mode 100644 x-pack/plugins/code/public/index.scss create mode 100644 x-pack/plugins/code/public/lib/documentation_links.ts create mode 100644 x-pack/plugins/code/public/monaco/blame/blame_widget.ts create mode 100644 x-pack/plugins/code/public/monaco/computer.ts create mode 100644 x-pack/plugins/code/public/monaco/content_widget.ts create mode 100644 x-pack/plugins/code/public/monaco/definition/definition_provider.ts create mode 100644 x-pack/plugins/code/public/monaco/disposable.ts create mode 100644 x-pack/plugins/code/public/monaco/editor_service.ts create mode 100644 x-pack/plugins/code/public/monaco/hover/content_hover_widget.ts create mode 100644 x-pack/plugins/code/public/monaco/hover/hover_computer.ts create mode 100644 x-pack/plugins/code/public/monaco/hover/hover_controller.ts create mode 100644 x-pack/plugins/code/public/monaco/immortal_reference.ts create mode 100644 x-pack/plugins/code/public/monaco/monaco.ts create mode 100644 x-pack/plugins/code/public/monaco/monaco_diff_editor.ts create mode 100644 x-pack/plugins/code/public/monaco/monaco_helper.ts create mode 100644 x-pack/plugins/code/public/monaco/operation.ts create mode 100644 x-pack/plugins/code/public/monaco/override_monaco_styles.scss create mode 100644 x-pack/plugins/code/public/monaco/references/references_action.ts create mode 100644 x-pack/plugins/code/public/monaco/single_selection_helper.ts create mode 100644 x-pack/plugins/code/public/monaco/textmodel_resolver.ts create mode 100644 x-pack/plugins/code/public/reducers/blame.ts create mode 100644 x-pack/plugins/code/public/reducers/commit.ts create mode 100644 x-pack/plugins/code/public/reducers/editor.ts create mode 100644 x-pack/plugins/code/public/reducers/file.ts create mode 100644 x-pack/plugins/code/public/reducers/index.ts create mode 100644 x-pack/plugins/code/public/reducers/language_server.ts create mode 100644 x-pack/plugins/code/public/reducers/repository.ts create mode 100644 x-pack/plugins/code/public/reducers/route.ts create mode 100644 x-pack/plugins/code/public/reducers/search.ts create mode 100644 x-pack/plugins/code/public/reducers/setup.ts create mode 100644 x-pack/plugins/code/public/reducers/shortcuts.ts create mode 100644 x-pack/plugins/code/public/reducers/status.ts create mode 100644 x-pack/plugins/code/public/reducers/symbol.ts create mode 100644 x-pack/plugins/code/public/sagas/blame.ts create mode 100644 x-pack/plugins/code/public/sagas/commit.ts create mode 100644 x-pack/plugins/code/public/sagas/editor.ts create mode 100644 x-pack/plugins/code/public/sagas/file.ts create mode 100644 x-pack/plugins/code/public/sagas/index.ts create mode 100644 x-pack/plugins/code/public/sagas/language_server.ts create mode 100644 x-pack/plugins/code/public/sagas/patterns.ts create mode 100644 x-pack/plugins/code/public/sagas/project_config.ts create mode 100644 x-pack/plugins/code/public/sagas/project_status.ts create mode 100644 x-pack/plugins/code/public/sagas/repository.ts create mode 100644 x-pack/plugins/code/public/sagas/search.ts create mode 100644 x-pack/plugins/code/public/sagas/setup.ts create mode 100644 x-pack/plugins/code/public/sagas/status.ts create mode 100644 x-pack/plugins/code/public/sagas/structure.ts create mode 100644 x-pack/plugins/code/public/selectors/index.ts create mode 100644 x-pack/plugins/code/public/stores/index.ts create mode 100644 x-pack/plugins/code/public/style/_buttons.scss create mode 100644 x-pack/plugins/code/public/style/_filetree.scss create mode 100644 x-pack/plugins/code/public/style/_filters.scss create mode 100644 x-pack/plugins/code/public/style/_layout.scss create mode 100644 x-pack/plugins/code/public/style/_markdown.scss create mode 100644 x-pack/plugins/code/public/style/_monaco.scss create mode 100644 x-pack/plugins/code/public/style/_query_bar.scss create mode 100644 x-pack/plugins/code/public/style/_shortcuts.scss create mode 100644 x-pack/plugins/code/public/style/_sidebar.scss create mode 100644 x-pack/plugins/code/public/style/_utilities.scss create mode 100644 x-pack/plugins/code/public/style/variables.ts create mode 100644 x-pack/plugins/code/public/utils/test_utils.ts create mode 100644 x-pack/plugins/code/public/utils/url.ts create mode 100644 x-pack/plugins/code/server/__tests__/clone_worker.ts create mode 100644 x-pack/plugins/code/server/__tests__/git_operations.ts create mode 100644 x-pack/plugins/code/server/__tests__/lsp_incremental_indexer.ts create mode 100644 x-pack/plugins/code/server/__tests__/lsp_indexer.ts create mode 100644 x-pack/plugins/code/server/__tests__/lsp_service.ts create mode 100644 x-pack/plugins/code/server/__tests__/multi_node.ts create mode 100644 x-pack/plugins/code/server/__tests__/repository_service.ts create mode 100644 x-pack/plugins/code/server/__tests__/workspace_handler.ts create mode 100644 x-pack/plugins/code/server/check_repos.ts create mode 100644 x-pack/plugins/code/server/git_operations.ts create mode 100644 x-pack/plugins/code/server/indexer/abstract_indexer.ts create mode 100644 x-pack/plugins/code/server/indexer/batch_index_helper.test.ts create mode 100644 x-pack/plugins/code/server/indexer/batch_index_helper.ts create mode 100644 x-pack/plugins/code/server/indexer/index.ts create mode 100644 x-pack/plugins/code/server/indexer/index_creation_request.ts create mode 100644 x-pack/plugins/code/server/indexer/index_creator.test.ts create mode 100644 x-pack/plugins/code/server/indexer/index_creator.ts create mode 100644 x-pack/plugins/code/server/indexer/index_migrator.test.ts create mode 100644 x-pack/plugins/code/server/indexer/index_migrator.ts create mode 100644 x-pack/plugins/code/server/indexer/index_version_controller.test.ts create mode 100644 x-pack/plugins/code/server/indexer/index_version_controller.ts create mode 100644 x-pack/plugins/code/server/indexer/indexer.ts create mode 100644 x-pack/plugins/code/server/indexer/lsp_incremental_indexer.ts create mode 100644 x-pack/plugins/code/server/indexer/lsp_indexer.ts create mode 100644 x-pack/plugins/code/server/indexer/lsp_indexer_factory.ts create mode 100644 x-pack/plugins/code/server/indexer/repository_index_initializer.test.ts create mode 100644 x-pack/plugins/code/server/indexer/repository_index_initializer.ts create mode 100644 x-pack/plugins/code/server/indexer/repository_index_initializer_factory.ts create mode 100644 x-pack/plugins/code/server/indexer/schema/document.ts create mode 100644 x-pack/plugins/code/server/indexer/schema/index.ts create mode 100644 x-pack/plugins/code/server/indexer/schema/reference.ts create mode 100644 x-pack/plugins/code/server/indexer/schema/repository.ts create mode 100644 x-pack/plugins/code/server/indexer/schema/symbol.ts create mode 100644 x-pack/plugins/code/server/indexer/schema/version.json create mode 100644 x-pack/plugins/code/server/init.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/constants/default_settings.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/constants/events.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/constants/events.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/constants/index.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/constants/statuses.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/constants/statuses.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/esqueue.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/esqueue.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/helpers/create_index.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/helpers/errors.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/helpers/index_timestamp.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/helpers/poller.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/index.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/index.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/job.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/job.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/misc.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/worker.d.ts create mode 100644 x-pack/plugins/code/server/lib/esqueue/worker.js create mode 100644 x-pack/plugins/code/server/lib/esqueue/yarn.lock create mode 100644 x-pack/plugins/code/server/log.ts create mode 100644 x-pack/plugins/code/server/lsp/controller.test.ts create mode 100644 x-pack/plugins/code/server/lsp/controller.ts create mode 100644 x-pack/plugins/code/server/lsp/go_launcher.ts create mode 100644 x-pack/plugins/code/server/lsp/http_message_reader.ts create mode 100644 x-pack/plugins/code/server/lsp/http_message_writer.ts create mode 100644 x-pack/plugins/code/server/lsp/http_request_emitter.ts create mode 100644 x-pack/plugins/code/server/lsp/install_manager.test.ts create mode 100644 x-pack/plugins/code/server/lsp/install_manager.ts create mode 100644 x-pack/plugins/code/server/lsp/java_launcher.ts create mode 100644 x-pack/plugins/code/server/lsp/language_server_launcher.test.ts create mode 100644 x-pack/plugins/code/server/lsp/language_server_launcher.ts create mode 100644 x-pack/plugins/code/server/lsp/language_servers.ts create mode 100644 x-pack/plugins/code/server/lsp/lsp_benchmark.ts create mode 100644 x-pack/plugins/code/server/lsp/lsp_service.ts create mode 100644 x-pack/plugins/code/server/lsp/lsp_test_runner.ts create mode 100644 x-pack/plugins/code/server/lsp/proxy.ts create mode 100644 x-pack/plugins/code/server/lsp/replies_map.ts create mode 100644 x-pack/plugins/code/server/lsp/request_expander.test.ts create mode 100644 x-pack/plugins/code/server/lsp/request_expander.ts create mode 100644 x-pack/plugins/code/server/lsp/test_config.yml create mode 100644 x-pack/plugins/code/server/lsp/test_repo_manager.ts create mode 100644 x-pack/plugins/code/server/lsp/ts_launcher.ts create mode 100644 x-pack/plugins/code/server/lsp/workspace_command.ts create mode 100644 x-pack/plugins/code/server/lsp/workspace_handler.test.ts create mode 100644 x-pack/plugins/code/server/lsp/workspace_handler.ts create mode 100644 x-pack/plugins/code/server/poller.ts create mode 100644 x-pack/plugins/code/server/queue/abstract_git_worker.ts create mode 100644 x-pack/plugins/code/server/queue/abstract_worker.ts create mode 100644 x-pack/plugins/code/server/queue/cancellation_service.test.ts create mode 100644 x-pack/plugins/code/server/queue/cancellation_service.ts create mode 100644 x-pack/plugins/code/server/queue/clone_worker.ts create mode 100644 x-pack/plugins/code/server/queue/delete_worker.test.ts create mode 100644 x-pack/plugins/code/server/queue/delete_worker.ts create mode 100644 x-pack/plugins/code/server/queue/index.ts create mode 100644 x-pack/plugins/code/server/queue/index_worker.test.ts create mode 100644 x-pack/plugins/code/server/queue/index_worker.ts create mode 100644 x-pack/plugins/code/server/queue/job.ts create mode 100644 x-pack/plugins/code/server/queue/update_worker.test.ts create mode 100644 x-pack/plugins/code/server/queue/update_worker.ts create mode 100644 x-pack/plugins/code/server/queue/worker.ts create mode 100644 x-pack/plugins/code/server/repository_config_controller.ts create mode 100644 x-pack/plugins/code/server/repository_service.ts create mode 100644 x-pack/plugins/code/server/repository_service_factory.ts create mode 100644 x-pack/plugins/code/server/routes/file.ts create mode 100644 x-pack/plugins/code/server/routes/install.ts create mode 100644 x-pack/plugins/code/server/routes/lsp.ts create mode 100644 x-pack/plugins/code/server/routes/redirect.ts create mode 100644 x-pack/plugins/code/server/routes/repository.ts create mode 100644 x-pack/plugins/code/server/routes/search.ts create mode 100644 x-pack/plugins/code/server/routes/setup.ts create mode 100644 x-pack/plugins/code/server/routes/workspace.ts create mode 100644 x-pack/plugins/code/server/scheduler/abstract_scheduler.ts create mode 100644 x-pack/plugins/code/server/scheduler/index.ts create mode 100644 x-pack/plugins/code/server/scheduler/index_scheduler.test.ts create mode 100644 x-pack/plugins/code/server/scheduler/index_scheduler.ts create mode 100644 x-pack/plugins/code/server/scheduler/update_scheduler.test.ts create mode 100644 x-pack/plugins/code/server/scheduler/update_scheduler.ts create mode 100644 x-pack/plugins/code/server/search/abstract_search_client.ts create mode 100644 x-pack/plugins/code/server/search/document_search_client.test.ts create mode 100644 x-pack/plugins/code/server/search/document_search_client.ts create mode 100644 x-pack/plugins/code/server/search/index.ts create mode 100644 x-pack/plugins/code/server/search/repository_object_client.test.ts create mode 100644 x-pack/plugins/code/server/search/repository_object_client.ts create mode 100644 x-pack/plugins/code/server/search/repository_search_client.test.ts create mode 100644 x-pack/plugins/code/server/search/repository_search_client.ts create mode 100644 x-pack/plugins/code/server/search/search_client.ts create mode 100644 x-pack/plugins/code/server/search/symbol_search_client.test.ts create mode 100644 x-pack/plugins/code/server/search/symbol_search_client.ts create mode 100644 x-pack/plugins/code/server/security.ts create mode 100644 x-pack/plugins/code/server/server_options.ts create mode 100644 x-pack/plugins/code/server/test_utils.ts create mode 100644 x-pack/plugins/code/server/utils/buffer.test.ts create mode 100644 x-pack/plugins/code/server/utils/buffer.ts create mode 100644 x-pack/plugins/code/server/utils/composite_source_merger.test.ts create mode 100644 x-pack/plugins/code/server/utils/composite_source_merger.ts create mode 100644 x-pack/plugins/code/server/utils/console_logger.ts create mode 100644 x-pack/plugins/code/server/utils/console_logger_factory.ts create mode 100644 x-pack/plugins/code/server/utils/detect_language.ts create mode 100644 x-pack/plugins/code/server/utils/es_index_client.ts create mode 100644 x-pack/plugins/code/server/utils/esclient_with_request.ts create mode 100644 x-pack/plugins/code/server/utils/extensions.json create mode 100644 x-pack/plugins/code/server/utils/index_stats_aggregator.ts create mode 100644 x-pack/plugins/code/server/utils/log_factory.ts create mode 100644 x-pack/plugins/code/server/utils/server_logger_factory.ts create mode 100644 x-pack/plugins/code/server/utils/timeout.ts create mode 100644 x-pack/plugins/code/server/utils/with_request.ts create mode 100644 x-pack/plugins/code/tasks/nodegit_info.ts create mode 100644 x-pack/plugins/code/webpackShims/stats-lite.d.ts create mode 100644 x-pack/test/api_integration/apis/code/feature_controls.ts create mode 100644 x-pack/test/api_integration/apis/code/index.ts create mode 100644 x-pack/test/functional/apps/code/code_intelligence.ts create mode 100644 x-pack/test/functional/apps/code/explore_repository.ts create mode 100644 x-pack/test/functional/apps/code/history.ts create mode 100644 x-pack/test/functional/apps/code/index.ts create mode 100644 x-pack/test/functional/apps/code/lib/types.ts create mode 100644 x-pack/test/functional/apps/code/manage_repositories.ts create mode 100644 x-pack/test/functional/apps/code/search.ts create mode 100644 x-pack/test/functional/apps/code/with_security.ts create mode 100644 x-pack/test/functional/es_archives/code/data.json create mode 100644 x-pack/test/functional/es_archives/code/mappings.json create mode 100644 x-pack/test/functional/page_objects/code_page.ts diff --git a/package.json b/package.json index 210f38ac6282bee..17b7e8fcb90b7fd 100644 --- a/package.json +++ b/package.json @@ -208,7 +208,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", @@ -274,6 +275,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", @@ -286,7 +288,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", @@ -322,7 +324,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", @@ -400,7 +402,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", @@ -414,7 +416,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 d13e8d6a26d272c..8f83f30a8e36a32 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 2ccc370796d596d..c4ebe4cc3c674db 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 1ec957f80128be83d85fc12e5a657a8e1f059f9b..544abf86007e668a94bc12b4fe9591047452c145 100644 GIT binary patch literal 2988 zcmWIWW@h1H0D)~0J|18Ol;C5KVMxtMEH24RE>2A>O3u&^4dG;9=J44YcD=qlrnG{a zfsy4KBLf4A2vA1=+?*N3F@DJAumjD>&P+!2t4!#j&Th^MJf}KrD^jn#{bE)C#?dRCWi5<*|FYATc>RF+CON>HIu=-i{5pn|DY-WZ(N>gSnoV}!%$^>|`g-{iD|MB`#G<7ugBNxPiR?Y+A>U-3XQsU6B(n~;P`##S zvFyF%pn?E4=IckZ*RTXJM{;Ig$V#|Xc3yo}-Q4GjS@V`Zn&o3Q>Ef*A``^37hke)M zCzEzu10TQrI>k^Z;{p z>TlL7VMTL}{*Kx5U@`m0(z6yzdTuAJWs%TH|636ACEz*h)uq2!5eXMPqeu!z5?{>{Bah2HGeWxk=!O#aSb3+C?6`=8sRsAjHvd$#YM-)P z|EWB}-=LhdMxS3>2dTtSBKsKxsVHj#91vD5`N#QXVvx%mNZ_& zZZL9%1+)T7As^t42wco63g&i@6_!AopaFsGc6=2U&`PY87060NL4aCaA^U0pb}JE; z7d}5BEI_RskS&+ckFX7xMIFQjNbU_{Wk4?YfX0CeK0H|hy@26C3HGT#R$X)2Jm4Lezl)JRqP+f~!IO1|SD^M2$ O10N7d0^L>%<^cdfumkS^ literal 2314 zcmWIWW@Zs#0D)~0J|18Ol;8u>sX2+oC7H>^sfk6&8TtWmwPum1YT1Emvon(t^AhnW z5=JO0Ni8nXEyyWN&&&gB-~!wEDfx!mQnvjaJU|`@%VO7{m!4UYnVy$ll$w{Hk{X|z zpHiBWS`2a=#2D|oLU*uvP(l~GF{u?LMTrV&nK`Kn`FS~&3YmEdRhb2PB^4zwe{wK@ zTx4*Brgq;F2k9>uB?XUG7g~(`vJ6 zZ`rn8Jo4ea*orfWlV{c%@7>SZE?Rek-{=tMyGE;x^A=Rxyf#7e;cP}8jYI{7n!{4z z;^K1Cx_ov7GGe6&TyKydZp?h1njIq4{(W_z8lQUJsek2KvvacQP<<8C%65p8k%8e35X)otRY78M zc4B%e&|mp^u@lbb9aa!%d0)x($~#I^vG1;1R$guY3ZY}#($krerbmTME!R(YzWm{_ zn%&0C`?Zg8Deqfov8jn6^L5FKDVk=Se;V9VeUqx=J{5&UJG`8_xRr;olXKd%n}0X4 zrw2~_8a&6#GH~(9r%U_-WljZsu+p@hX#K8C*`$!YLaO7>oVm{|_S|$y4r23su_rr+ z8whpRI<9WazHK|dtK#*Z1q)nPbxE7uJm8i+@8A63kDJa#o_;JdL#!vHK>WrFhTc!P zUccA;uwH-KyZ(aGw23tbc)ss>)X(EsaWnnTvD4CWYfe3C-2ds9Qr2y*Iqk0{O0K%| zf6D%7;nJU-yjDfx%>7>vTizr_tJcK*WrT$y0|R;;uVSPvfLZBP(kIj}!;js8M5?T?uW6TMF?tayI>#%|-8u2UB7P@BAt zqj~!2gd^6B)sHu9$hjN%y_jU&IDuy62;4Mrw=23&=V z3Q#Es2tX*bB8LkcJPZts3=#}>1qWwuPrb;9P9vL-9x3b$$mZ(+?SK|j$OhkAvQTRW z&|nyS#q%M5rQ$_kTq7KgZm=+l!GQ>ak&8BDOP1R%0$T#3SC&<61^EVI39bSV=6VI7 z1x?s3K@^9`)-il(W>~ZC0%PS2h+kkdvUQjRC9-uVv0I0`*hH~@vD?>1U=V8VGMotV zC5%P{DYn8D*?JLR!32#faQcm}Z$ zMk6f5Ry@Eg1f_HXW~{y|fD{(UcEQpMjCM15gqAq56)7;g6o7VR;I#`Wvkd1Z?Pnr)(eCTw5 UH!B-Z9|JcKP6TRB24V&V0DPC7_W%F@ diff --git a/src/cli_plugin/install/zip.js b/src/cli_plugin/install/zip.js index 25cdac42398db03..7f928275525d5e5 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 516e0abe25743d7..340dec196eef5ba 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 bb450dcd006bc53..acbbddcce8e725e 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 acf4680fd6f427b..c471a7aafe11859 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 000000000000000..0d55f225fd74ea5 --- /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 87201d0e758c166..d9cdcbc596b33ec 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 3c9f683ed0796aa..86bed4bcc8851fa 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 69b1369d8fef959..3dd4ea2d6e580f2 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", @@ -134,7 +149,7 @@ "sass-loader": "^7.1.0", "sass-resources-loader": "^2.0.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", @@ -152,7 +167,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", @@ -197,26 +215,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", @@ -236,7 +261,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", @@ -247,7 +274,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", @@ -259,6 +288,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", @@ -274,6 +304,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", @@ -284,6 +315,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", @@ -299,6 +331,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 000000000000000..17f0622c69a30c0 --- /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 000000000000000..89f1bd1cf2fad23 --- /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 000000000000000..f3c53eb9564689d --- /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 000000000000000..27159cf91cc4275 --- /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 000000000000000..5a39ab227ac021a --- /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 000000000000000..74899a17a658a53 --- /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 000000000000000..c11b22acba3bd9e --- /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 000000000000000..a40ed0ebe5770e5 --- /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 000000000000000..c39384cefc7a27b --- /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 000000000000000..82353b671080f87 --- /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 000000000000000..bdaf1aba3335a9c --- /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.com-elastic-kibana-e2b881a9'); + expect(indexName2).toEqual('github.com-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 000000000000000..37813be8f80e64f --- /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 000000000000000..bc9bfcf477be4d4 --- /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 000000000000000..fbdc739b8f944a1 --- /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 000000000000000..706576039efb4e2 --- /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 000000000000000..7a4e799cf7fc834 --- /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 000000000000000..dc62a9c8ac8baee --- /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 000000000000000..3deacd72eb8a457 --- /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 000000000000000..e662ee51a80794d --- /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 000000000000000..f7933ddb1ae523a --- /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 000000000000000..30e4ba0f7141949 --- /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 000000000000000..cee855b0980fdc4 --- /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 000000000000000..f29659e6fd937c0 --- /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 000000000000000..9bc983b9c7f8f1a --- /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 000000000000000..45f8cf34684efb8 --- /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 000000000000000..efa48e28e738825 --- /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 000000000000000..e78a0d05eeffbea --- /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 000000000000000..aabf0e179beedff --- /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 000000000000000..146c54fca0fa1da --- /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 000000000000000..b91ead5889685e1 --- /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 000000000000000..6f12693e43bddee --- /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 000000000000000..9e4f22779150b2f --- /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 000000000000000..05de33da7f855b6 --- /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 000000000000000..e278744f10c767e --- /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 000000000000000..1ab96d449b5f92c --- /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 000000000000000..d6dac604fb5f061 --- /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 000000000000000..14f93b220d466f3 --- /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 000000000000000..4a29d0d7726645d --- /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 000000000000000..5b7639d95619fb0 --- /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 000000000000000..66e7965d4f578fd --- /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 000000000000000..c2122022b9d84d8 --- /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 000000000000000..19ed13673247372 --- /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 000000000000000..0f75513ed02f478 --- /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 { uiCapabilities } from 'ui/capabilities'; + +import { ImportProject } from './import_project'; + +export const EmptyProject = () => { + const isAdmin = uiCapabilities.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 000000000000000..dd8e82686f1f459 --- /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 000000000000000..8e31073726def24 --- /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 000000000000000..6e59eff899946fd --- /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 000000000000000..5a100cb573ca102 --- /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 000000000000000..6031e2df4630d47 --- /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 { uiCapabilities } 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 && ( + + )} + + + + + + + + + + + {(uiCapabilities.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 000000000000000..dee813b684e22fe --- /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 000000000000000..a000442629ec553 --- /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 000000000000000..aa4a4491f56fd3a --- /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 000000000000000..c2e79a5fd7490f6 --- /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 000000000000000..c402660599f5773 --- /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 000000000000000..4492f370c1ed6f2 --- /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 000000000000000..01b77d250b16112 --- /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 000000000000000..bf30230f98e6062 --- /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 000000000000000..fc3df8bb79a45cb --- /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 000000000000000..fa6635c543378e6 --- /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 000000000000000..f6cd6cbd4e9aa00 --- /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 000000000000000..ce2b92c95007d66 --- /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`] = ` + +`; 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 000000000000000..99a08f33de3c0f3 --- /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 000000000000000..9edd67fa9243fc5 --- /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 000000000000000..1a45cb34418a821 --- /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 000000000000000..47682169c51e034 --- /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 000000000000000..b9200676a730c84 --- /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 000000000000000..d58eda98aec149c --- /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 000000000000000..7fde1e8f19778d7 --- /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 000000000000000..99d79c532927d6a --- /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 000000000000000..8d16e26859017b2 --- /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 000000000000000..61984ae28b1631d --- /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 000000000000000..fc665b9d5da3c12 --- /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 ( +
+ {rawUrl} +
+ ); + } + 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 000000000000000..e3d60f2ab67d9f4 --- /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 000000000000000..dfb39be64c4b3df --- /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 000000000000000..f833c363591aad3 --- /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 000000000000000..0fa2d2a92b44f55 --- /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 000000000000000..ed385a65c700400 --- /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 000000000000000..b732546af982170 --- /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 000000000000000..459dce3a82517c0 --- /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 000000000000000..905612ab4a1be8b --- /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 000000000000000..942dbeca5f814e1 --- /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 000000000000000..481d0717555e909 --- /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 + + + + + + +
+ + + + + +
+
+
+
+ + +
+
+ + + + +
+ + +
+ +
+
+
+
+ + +
+
+ + + + +
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+ +
+
+
+
+
+ + +`; + +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 + + + + + + +
+ + + + + +
+
+
+
+ + +
+
+ + + + +
+ + +
+ +
+
+
+
+ + +
+
+ + + + +
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+ +
+
+
+
+ + + + + +`; 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 000000000000000..0c6b5535c34e3b4 --- /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 000000000000000..f3970dcd6f07313 --- /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 000000000000000..4e79d80689025db --- /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 000000000000000..d2495d8702f0a79 --- /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*/} +
+
+
+
+ + (this.optionFlyout = element)} + /> +
+
+
+ + +
+
+
+
+ ); + } +} + +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 000000000000000..052a56680292bee --- /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 000000000000000..daa1c1e2bf9f682 --- /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 000000000000000..1488a046833e845 --- /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 +
+
+
+
+
+
+
+ +
+ +
+ +
+ + + + + + + +
+
+ +
+ Files +
+
+
+
+
+ 1 + Result +
+
+
+ +
+
+
+
+ src/foo/bar.java +
+
+ This is a file +
+
+
+
+
+
+
+ +
+ +
+ +
+ + + + + + + +
+
+ +
+ Repos +
+
+
+
+
+ 2 + Result + s +
+
+
+ +
+
+
+
+ elastic/kibana +
+
+
+
+
+ + +
+
+
+
+ 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 000000000000000..d0e9a24849503a5 --- /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 000000000000000..884950906185efb --- /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 000000000000000..04de9a244f0cbb3 --- /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 000000000000000..0125aa0113cfa0b --- /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 000000000000000..8cd9383123677f1 --- /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 000000000000000..8919f97dd5a2859 --- /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 000000000000000..2e49e769f963285 --- /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 000000000000000..85d172aca5d79f9 --- /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 000000000000000..5c5a4129c1b49db --- /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 000000000000000..ef9846bd4aed9f1 --- /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 000000000000000..c40297f6bb38bad --- /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 000000000000000..0c4892b0188dd17 --- /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 000000000000000..873e1b7f5e30a83 --- /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 000000000000000..3a72dfd0a954dd5 --- /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 000000000000000..4655efa79310e4c --- /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 000000000000000..bc3ee4384b9b950 --- /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 000000000000000..cffa3e9e679aa76 --- /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 000000000000000..a96b0b20ed3bad4 --- /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 000000000000000..6515ddf2d0b7f9b --- /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 000000000000000..617315e1e12b3fc --- /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 000000000000000..0268ad049c7f295 --- /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 000000000000000..a56629a10c415c0 --- /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 000000000000000..7e5afc8b25e47dd --- /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 000000000000000..229e0a50dda3506 --- /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 000000000000000..e4cfbe1946e5d8d --- /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 000000000000000..9fb8cfb2ac4cf91 --- /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`] = ` +
+ +
+`; 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 000000000000000..4d86c3d45cc93b4 --- /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 000000000000000..334de88083d5dc4 --- /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 000000000000000..d39dd837d0ae765 --- /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 000000000000000..090366342939dfc --- /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 000000000000000..c750da3b064dcb3 --- /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 000000000000000..cf3b5993f21e3df --- /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 000000000000000..ef26b16849ba6aa --- /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 000000000000000..01a653aec08e966 --- /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 000000000000000..d0787904ebb8aa6 --- /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 000000000000000..d89048357234e6d --- /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 000000000000000..e38fb1f93587ce7 --- /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 000000000000000..ed5866d4cef6c23 --- /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 000000000000000..3dbea3712f30ee4 --- /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 000000000000000..40ec6c9c171f778 --- /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 000000000000000..b1ec674c1ee9c0c --- /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 000000000000000..9afb09f279a7889 --- /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 000000000000000..98486da772d84a6 --- /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 000000000000000..f1b6e3871cd5864 --- /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 000000000000000..0718166d65e3bfb --- /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 000000000000000..b5990ac7a827088 --- /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 000000000000000..58926c8008a5973 --- /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 000000000000000..f63296aeab55a13 --- /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 000000000000000..1336bc03a42627a --- /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 000000000000000..987a176a688de01 --- /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 000000000000000..4b0ce0e2afb6ad4 --- /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 000000000000000..174116e04607e6c --- /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 000000000000000..bee97cf2ff96ba5 --- /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 000000000000000..8c8ebbee447ae9a --- /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 000000000000000..4e96c3477223164 --- /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 000000000000000..6accf51345c1689 --- /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 000000000000000..74857074ca66b0d --- /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 000000000000000..7a6f56a9423f8bf --- /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 000000000000000..7d4d3ca7d39af16 --- /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 000000000000000..8829a024e1dddf9 --- /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 000000000000000..550185bdcd57d27 --- /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 000000000000000..116171c3f81868f --- /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 000000000000000..d79221f9c63ef17 --- /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 000000000000000..4235f047865fa7b --- /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 000000000000000..e29a2f5ff19e615 --- /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 000000000000000..1d229bf552d3d45 --- /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 000000000000000..41219b79ac7be2d --- /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 000000000000000..927892e3f49bb69 --- /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 000000000000000..53a007491f5d12f --- /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 000000000000000..fb1672f2c65ca94 --- /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 000000000000000..dea5b6629fd7185 --- /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 000000000000000..25a679736838054 --- /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 000000000000000..f378ea0c869db4a --- /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 000000000000000..62f8cdb6cadbc12 --- /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 000000000000000..c418d8384ef5e44 --- /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 000000000000000..f574cf6ab9175f7 --- /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 000000000000000..036b899a93c15e0 --- /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 000000000000000..b502ba97aa676ee --- /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 000000000000000..1f9dc1ff84e73fd --- /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 000000000000000..66827dc89b7321a --- /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 000000000000000..e360ec75c86edc0 --- /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 000000000000000..c2d157c02d95ddb --- /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 000000000000000..01ceb042f0348f9 --- /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 000000000000000..f2e6eb8d1d55882 --- /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 000000000000000..9f00d96b10b72c5 --- /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 000000000000000..2cf519b7f2562d4 --- /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 000000000000000..9986232686bad63 --- /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 000000000000000..3dda460e8f9f56c --- /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; +} 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 000000000000000..eeeba1000f575d7 --- /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 000000000000000..e88dbafb614142e --- /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 000000000000000..0208434eeb5df1a --- /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 000000000000000..f27281ab513ccc9 --- /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 000000000000000..0b87d97e0475685 --- /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 000000000000000..2bc81619c5e00b1 --- /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 000000000000000..4773e5ab35a5853 --- /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 000000000000000..0bb557323db1138 --- /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 000000000000000..68b293717a9dc2e --- /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 000000000000000..7eaec9af646debf --- /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 000000000000000..535a1effe8059cd --- /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 000000000000000..4b23ecff2dc3dc9 --- /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 000000000000000..6f0a4267982bd14 --- /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 000000000000000..bc7b0b8c30ab767 --- /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((resolve, reject) => { + resolve(); + }); + } + + protected async *getIndexRequestIterator(): AsyncIterableIterator { + // This is the abstract implementation. You should override this. + } + + protected async getIndexRequestCount(): Promise { + // This is the abstract implementation. You should override this. + return new Promise((resolve, reject) => { + resolve(); + }); + } + + protected async processRequest(request: IndexRequest): Promise { + // This is the abstract implementation. You should override this. + return new Promise((resolve, reject) => { + resolve(); + }); + } + + protected async prepareIndexCreationRequests(): Promise { + // This is the abstract implementation. You should override this. + return new Promise((resolve, reject) => { + resolve(); + }); + } + + protected async prepareIndex() { + const creationReqs = await this.prepareIndexCreationRequests(); + for (const req of creationReqs) { + try { + const res = await this.indexCreator.createIndex(req); + if (!res) { + this.log.info(`Index creation failed for ${req.index}.`); + return false; + } + } catch (error) { + this.log.error(`Index creation error.`); + this.log.error(error); + return false; + } + } + return true; + } +} diff --git a/x-pack/plugins/code/server/indexer/batch_index_helper.test.ts b/x-pack/plugins/code/server/indexer/batch_index_helper.test.ts new file mode 100644 index 000000000000000..9ba3f610126696d --- /dev/null +++ b/x-pack/plugins/code/server/indexer/batch_index_helper.test.ts @@ -0,0 +1,71 @@ +/* + * 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 sinon from 'sinon'; + +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { BatchIndexHelper } from './batch_index_helper'; + +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const BATCH_INDEX_SIZE = 5; + +const esClient = { + bulk: emptyAsyncFunc, +}; + +afterEach(() => { + sinon.restore(); +}); + +test('Execute bulk index.', async () => { + // Setup the bulk stub + const bulkSpy = sinon.spy(); + esClient.bulk = bulkSpy; + + const batchIndexHelper = new BatchIndexHelper(esClient as EsClient, log, BATCH_INDEX_SIZE); + + // Submit index requests as many as 2 batches. + for (let i = 0; i < BATCH_INDEX_SIZE * 2; i++) { + await batchIndexHelper.index('mockindex', {}); + } + + expect(bulkSpy.calledTwice).toBeTruthy(); +}); + +test('Do not execute bulk index without enough requests.', async () => { + // Setup the bulk stub + const bulkSpy = sinon.spy(); + esClient.bulk = bulkSpy; + + const batchIndexHelper = new BatchIndexHelper(esClient as EsClient, log, BATCH_INDEX_SIZE); + + // Submit index requests less than one batch. + for (let i = 0; i < BATCH_INDEX_SIZE - 1; i++) { + await batchIndexHelper.index('mockindex', {}); + } + + expect(bulkSpy.notCalled).toBeTruthy(); +}); + +test('Skip bulk index if cancelled.', async () => { + // Setup the bulk stub + const bulkSpy = sinon.spy(); + esClient.bulk = bulkSpy; + + const batchIndexHelper = new BatchIndexHelper(esClient as EsClient, log, BATCH_INDEX_SIZE); + batchIndexHelper.cancel(); + + // Submit index requests more than one batch. + for (let i = 0; i < BATCH_INDEX_SIZE + 1; i++) { + await batchIndexHelper.index('mockindex', {}); + } + + expect(bulkSpy.notCalled).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/indexer/batch_index_helper.ts b/x-pack/plugins/code/server/indexer/batch_index_helper.ts new file mode 100644 index 000000000000000..cd4f361d3013622 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/batch_index_helper.ts @@ -0,0 +1,71 @@ +/* + * 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 { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; + +/* + * This BatchIndexHelper acts as the index request cache for elasticsearch + * bulk index API. + */ +export class BatchIndexHelper { + public static DEFAULT_BATCH_SIZE = 1000; + private batch: any[]; + private cancelled: boolean = false; + + constructor( + protected readonly client: EsClient, + protected readonly log: Logger, + private batchSize: number = BatchIndexHelper.DEFAULT_BATCH_SIZE + ) { + this.batch = []; + } + + public async index(index: string, body: any) { + if (this.isCancelled()) { + this.log.debug(`Batch index helper is cancelled. Skip.`); + return; + } + this.batch.push({ + index: { + _index: index, + }, + }); + this.batch.push(body); + if (this.batch.length >= this.batchSize * 2) { + return await this.flush(); + } + } + + public async flush() { + if (this.batch.length === 0) { + this.log.debug(`0 index requests found. Skip.`); + return; + } + if (this.isCancelled()) { + this.log.debug(`Batch index helper is cancelled. Skip.`); + return; + } + try { + this.log.info(`Batch indexed ${this.batch.length / 2} documents.`); + return await this.client.bulk({ body: this.batch }); + } catch (error) { + // TODO(mengwei): should we throw this exception again? + this.log.error(`Batch index ${this.batch.length / 2} documents error. Skip.`); + this.log.error(error); + } finally { + this.batch = []; + } + } + + public isCancelled() { + return this.cancelled; + } + + public cancel() { + this.cancelled = true; + } +} diff --git a/x-pack/plugins/code/server/indexer/index.ts b/x-pack/plugins/code/server/indexer/index.ts new file mode 100644 index 000000000000000..e25d60f7aa2178c --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index.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 * from './lsp_incremental_indexer'; +export * from './lsp_indexer'; +export * from './lsp_indexer_factory'; +export * from './indexer'; +export * from './index_creation_request'; +export * from './index_creator'; +export * from './index_migrator'; +export * from './index_version_controller'; +export * from './repository_index_initializer'; +export * from './repository_index_initializer_factory'; diff --git a/x-pack/plugins/code/server/indexer/index_creation_request.ts b/x-pack/plugins/code/server/indexer/index_creation_request.ts new file mode 100644 index 000000000000000..652243d4c4e0f21 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index_creation_request.ts @@ -0,0 +1,58 @@ +/* + * 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 { + DocumentAnalysisSettings, + DocumentIndexName, + DocumentSchema, + ReferenceIndexName, + ReferenceSchema, + SymbolAnalysisSettings, + SymbolIndexName, + SymbolSchema, +} from './schema'; + +export interface IndexCreationRequest { + index: string; + settings: any; + schema: any; +} + +export const getDocumentIndexCreationRequest = (repoUri: RepositoryUri): IndexCreationRequest => { + return { + index: DocumentIndexName(repoUri), + settings: { + ...DocumentAnalysisSettings, + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, + schema: DocumentSchema, + }; +}; + +export const getSymbolIndexCreationRequest = (repoUri: RepositoryUri): IndexCreationRequest => { + return { + index: SymbolIndexName(repoUri), + settings: { + ...SymbolAnalysisSettings, + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, + schema: SymbolSchema, + }; +}; + +export const getReferenceIndexCreationRequest = (repoUri: RepositoryUri): IndexCreationRequest => { + return { + index: ReferenceIndexName(repoUri), + settings: { + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, + schema: ReferenceSchema, + }; +}; diff --git a/x-pack/plugins/code/server/indexer/index_creator.test.ts b/x-pack/plugins/code/server/indexer/index_creator.test.ts new file mode 100644 index 000000000000000..3f89023e0b6302c --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index_creator.test.ts @@ -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 sinon from 'sinon'; + +import { EsClient } from '../lib/esqueue'; +import { emptyAsyncFunc } from '../test_utils'; +import { IndexCreationRequest } from './index_creation_request'; +import { IndexCreator } from './index_creator'; + +const esClient = { + indices: { + existsAlias: emptyAsyncFunc, + create: emptyAsyncFunc, + putAlias: emptyAsyncFunc, + }, +}; + +afterEach(() => { + sinon.restore(); +}); + +test('Create Alias and Index', async () => { + // Setup the esClient spies + const existsAliasSpy = sinon.fake.returns(false); + const createSpy = sinon.spy(); + const putAliasSpy = sinon.spy(); + esClient.indices.existsAlias = existsAliasSpy; + esClient.indices.create = createSpy; + esClient.indices.putAlias = putAliasSpy; + + const indexCreator = new IndexCreator((esClient as any) as EsClient); + const req: IndexCreationRequest = { + index: 'mockindex', + settings: {}, + schema: {}, + }; + await indexCreator.createIndex(req); + + expect(existsAliasSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledOnce).toBeTruthy(); + expect(putAliasSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledAfter(existsAliasSpy)).toBeTruthy(); + expect(putAliasSpy.calledAfter(createSpy)).toBeTruthy(); +}); + +test('Skip alias and index creation', async () => { + // Setup the esClient spies + const existsAliasSpy = sinon.fake.returns(true); + const createSpy = sinon.spy(); + const putAliasSpy = sinon.spy(); + esClient.indices.existsAlias = existsAliasSpy; + esClient.indices.create = createSpy; + esClient.indices.putAlias = putAliasSpy; + + const indexCreator = new IndexCreator((esClient as any) as EsClient); + const req: IndexCreationRequest = { + index: 'mockindex', + settings: {}, + schema: {}, + }; + await indexCreator.createIndex(req); + + expect(existsAliasSpy.calledOnce).toBeTruthy(); + expect(createSpy.notCalled).toBeTruthy(); + expect(putAliasSpy.notCalled).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/indexer/index_creator.ts b/x-pack/plugins/code/server/indexer/index_creator.ts new file mode 100644 index 000000000000000..917db9b601e6d2d --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index_creator.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 { EsClient } from '../lib/esqueue'; +import { IndexCreationRequest } from './index_creation_request'; +import pkg from './schema/version.json'; + +/* + * This IndexCreator deals with anything with elasticsearch index creation. + */ +export class IndexCreator { + private version: number; + + constructor(private readonly client: EsClient) { + this.version = Number(pkg.codeIndexVersion); + } + + public async createIndex(request: IndexCreationRequest): Promise { + const body = { + settings: request.settings, + mappings: { + // Apply the index version in the reserved _meta field of the index. + _meta: { + version: this.version, + }, + dynamic_templates: [ + { + fieldDefaultNotAnalyzed: { + match: '*', + mapping: { + index: false, + norms: false, + }, + }, + }, + ], + properties: request.schema, + }, + }; + + const exists = await this.client.indices.existsAlias({ + name: request.index, + }); + if (!exists) { + // Create the actual index first with the version as the index suffix number. + await this.client.indices.create({ + index: `${request.index}-${this.version}`, + body, + }); + + // Create the alias to point the index just created. + await this.client.indices.putAlias({ + index: `${request.index}-${this.version}`, + name: request.index, + }); + + return true; + } + return exists; + } +} diff --git a/x-pack/plugins/code/server/indexer/index_migrator.test.ts b/x-pack/plugins/code/server/indexer/index_migrator.test.ts new file mode 100644 index 000000000000000..2cb977b8cb9c016 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index_migrator.test.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 sinon from 'sinon'; + +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { IndexCreationRequest } from './index_creation_request'; +import { IndexMigrator } from './index_migrator'; + +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esClient = { + reindex: emptyAsyncFunc, + indices: { + create: emptyAsyncFunc, + updateAliases: emptyAsyncFunc, + delete: emptyAsyncFunc, + }, +}; + +afterEach(() => { + sinon.restore(); +}); + +test('Normal index migration steps.', async () => { + // Setup the esClient spies + const updateAliasesSpy = sinon.spy(); + const createSpy = sinon.spy(); + const deleteSpy = sinon.spy(); + const reindexSpy = sinon.spy(); + esClient.indices.updateAliases = updateAliasesSpy; + esClient.indices.create = createSpy; + esClient.indices.delete = deleteSpy; + esClient.reindex = reindexSpy; + + const migrator = new IndexMigrator(esClient as EsClient, log); + const req: IndexCreationRequest = { + index: 'mockindex', + settings: {}, + schema: {}, + }; + await migrator.migrateIndex('mockoldindex', req); + + expect(createSpy.calledOnce).toBeTruthy(); + expect(reindexSpy.calledOnce).toBeTruthy(); + expect(updateAliasesSpy.calledOnce).toBeTruthy(); + expect(deleteSpy.calledOnce).toBeTruthy(); + expect(reindexSpy.calledAfter(createSpy)).toBeTruthy(); + expect(updateAliasesSpy.calledAfter(reindexSpy)).toBeTruthy(); + expect(deleteSpy.calledAfter(updateAliasesSpy)).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/indexer/index_migrator.ts b/x-pack/plugins/code/server/indexer/index_migrator.ts new file mode 100644 index 000000000000000..e30e5475a47f8db --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index_migrator.ts @@ -0,0 +1,146 @@ +/* + * 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 { + getDocumentIndexCreationRequest, + getReferenceIndexCreationRequest, + getSymbolIndexCreationRequest, + IndexCreationRequest, + IndexVersionController, +} from '.'; +import { Repository } from '../../model'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositoryObjectClient } from '../search'; +import pkg from './schema/version.json'; + +export class IndexMigrator { + private version: number; + + constructor(private readonly client: EsClient, private readonly log: Logger) { + this.version = Number(pkg.codeIndexVersion); + } + + public async migrateIndex(oldIndexName: string, request: IndexCreationRequest) { + const body = { + settings: request.settings, + mappings: { + // Apply the index version in the reserved _meta field of the index. + _meta: { + version: this.version, + }, + dynamic_templates: [ + { + fieldDefaultNotAnalyzed: { + match: '*', + mapping: { + index: false, + norms: false, + }, + }, + }, + ], + properties: request.schema, + }, + }; + + const newIndexName = `${request.index}-${this.version}`; + + try { + try { + // Create the new index first with the version as the index suffix number. + await this.client.indices.create({ + index: newIndexName, + body, + }); + } catch (error) { + this.log.error(`Create new index ${newIndexName} for index migration error.`); + this.log.error(error); + throw error; + } + + try { + // Issue the reindex request for import the data from the old index. + await this.client.reindex({ + body: { + source: { + index: oldIndexName, + }, + dest: { + index: newIndexName, + }, + }, + }); + } catch (error) { + this.log.error( + `Migrate data from ${oldIndexName} to ${newIndexName} for index migration error.` + ); + this.log.error(error); + throw error; + } + + try { + // Update the alias + await this.client.indices.updateAliases({ + body: { + actions: [ + { + remove: { + index: oldIndexName, + alias: request.index, + }, + }, + { + add: { + index: newIndexName, + alias: request.index, + }, + }, + ], + }, + }); + } catch (error) { + this.log.error(`Update the index alias for ${newIndexName} error.`); + this.log.error(error); + throw error; + } + + try { + // Delete the old index + await this.client.indices.delete({ index: oldIndexName }); + } catch (error) { + this.log.error(`Clean up the old index ${oldIndexName} error.`); + this.log.error(error); + // This won't affect serving, so do not throw the error anymore. + } + } catch (error) { + this.log.error(`Index upgrade/migration to version ${this.version} failed.`); + this.log.error(error); + } + } +} + +export const tryMigrateIndices = async (client: EsClient, log: Logger) => { + log.info('Check the versions of Code indices...'); + const repoObjectClient = new RepositoryObjectClient(client); + const repos: Repository[] = await repoObjectClient.getAllRepositories(); + + const migrationPromises = []; + for (const repo of repos) { + const docIndexVersionController = new IndexVersionController(client, log); + const docCreationReq = getDocumentIndexCreationRequest(repo.uri); + migrationPromises.push(docIndexVersionController.tryUpgrade(docCreationReq)); + + const symbolIndexVersionController = new IndexVersionController(client, log); + const symbolCreationReq = getSymbolIndexCreationRequest(repo.uri); + migrationPromises.push(symbolIndexVersionController.tryUpgrade(symbolCreationReq)); + + const refIndexVersionController = new IndexVersionController(client, log); + const refCreationReq = getReferenceIndexCreationRequest(repo.uri); + migrationPromises.push(refIndexVersionController.tryUpgrade(refCreationReq)); + } + return Promise.all(migrationPromises); +}; diff --git a/x-pack/plugins/code/server/indexer/index_version_controller.test.ts b/x-pack/plugins/code/server/indexer/index_version_controller.test.ts new file mode 100644 index 000000000000000..57724d9e47dcbab --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index_version_controller.test.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 sinon from 'sinon'; + +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { IndexCreationRequest } from './index_creation_request'; +import { IndexVersionController } from './index_version_controller'; +import pkg from './schema/version.json'; + +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esClient = { + reindex: emptyAsyncFunc, + indices: { + getMapping: emptyAsyncFunc, + create: emptyAsyncFunc, + updateAliases: emptyAsyncFunc, + delete: emptyAsyncFunc, + }, +}; + +afterEach(() => { + sinon.restore(); +}); + +test('Index upgrade is triggered.', async () => { + // Setup the esClient spies + const getMappingSpy = sinon.fake.returns( + Promise.resolve({ + mockindex: { + mappings: { + _meta: { + version: 0, + }, + }, + }, + }) + ); + const updateAliasesSpy = sinon.spy(); + const createSpy = sinon.spy(); + const deleteSpy = sinon.spy(); + const reindexSpy = sinon.spy(); + esClient.indices.getMapping = getMappingSpy; + esClient.indices.updateAliases = updateAliasesSpy; + esClient.indices.create = createSpy; + esClient.indices.delete = deleteSpy; + esClient.reindex = reindexSpy; + + const versionController = new IndexVersionController(esClient as EsClient, log); + const req: IndexCreationRequest = { + index: 'mockindex', + settings: {}, + schema: {}, + }; + await versionController.tryUpgrade(req); + + expect(getMappingSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledOnce).toBeTruthy(); + expect(reindexSpy.calledOnce).toBeTruthy(); + expect(updateAliasesSpy.calledOnce).toBeTruthy(); + expect(deleteSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledAfter(getMappingSpy)).toBeTruthy(); + expect(reindexSpy.calledAfter(getMappingSpy)).toBeTruthy(); + expect(updateAliasesSpy.calledAfter(getMappingSpy)).toBeTruthy(); + expect(deleteSpy.calledAfter(getMappingSpy)).toBeTruthy(); +}); + +test('Index upgrade is skipped.', async () => { + // Setup the esClient spies + const getMappingSpy = sinon.fake.returns( + Promise.resolve({ + mockindex: { + mappings: { + _meta: { + version: pkg.codeIndexVersion, + }, + }, + }, + }) + ); + const updateAliasesSpy = sinon.spy(); + const createSpy = sinon.spy(); + const deleteSpy = sinon.spy(); + const reindexSpy = sinon.spy(); + esClient.indices.getMapping = getMappingSpy; + esClient.indices.updateAliases = updateAliasesSpy; + esClient.indices.create = createSpy; + esClient.indices.delete = deleteSpy; + esClient.reindex = reindexSpy; + + const versionController = new IndexVersionController(esClient as EsClient, log); + const req: IndexCreationRequest = { + index: 'mockindex', + settings: {}, + schema: {}, + }; + await versionController.tryUpgrade(req); + + expect(getMappingSpy.calledOnce).toBeTruthy(); + expect(createSpy.notCalled).toBeTruthy(); + expect(reindexSpy.notCalled).toBeTruthy(); + expect(updateAliasesSpy.notCalled).toBeTruthy(); + expect(deleteSpy.notCalled).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/indexer/index_version_controller.ts b/x-pack/plugins/code/server/indexer/index_version_controller.ts new file mode 100644 index 000000000000000..aa087337bb6ac15 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/index_version_controller.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 _ from 'lodash'; + +import { IndexMigrator } from '.'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { IndexCreationRequest } from './index_creation_request'; +import pkg from './schema/version.json'; + +export class IndexVersionController { + private version: number; + + constructor(protected readonly client: EsClient, private readonly log: Logger) { + this.version = Number(pkg.codeIndexVersion); + } + + public async tryUpgrade(request: IndexCreationRequest) { + this.log.debug(`Try upgrade index mapping/settings for index ${request.index}.`); + const esIndexVersion = await this.getIndexVersionFromES(request.index); + const needUpgrade = this.needUpgrade(esIndexVersion); + if (needUpgrade) { + const migrator = new IndexMigrator(this.client, this.log); + const oldIndexName = `${request.index}-${esIndexVersion}`; + this.log.warn( + `Migrate index mapping/settings from version ${esIndexVersion} for ${request.index}` + ); + return migrator.migrateIndex(oldIndexName, request); + } else { + this.log.debug(`Index version is update-to-date for ${request.index}`); + } + } + + /* + * Currently there is a simple rule to decide if we need upgrade the index or not: if the index + * version is smaller than current version specified in the package.json file under `codeIndexVersion`. + */ + protected needUpgrade(oldIndexVersion: number): boolean { + return oldIndexVersion < this.version; + } + + private async getIndexVersionFromES(indexName: string): Promise { + try { + const res = await this.client.indices.getMapping({ + index: indexName, + }); + const esIndexName = Object.keys(res)[0]; + const version = _.get(res, [esIndexName, 'mappings', '_meta', 'version'], 0); + if (version === 0) { + this.log.error(`Can't find index version for ${indexName}.`); + } + return version; + } catch (error) { + this.log.error(`Get index version error for ${indexName}.`); + this.log.error(error); + return 0; + } + } +} diff --git a/x-pack/plugins/code/server/indexer/indexer.ts b/x-pack/plugins/code/server/indexer/indexer.ts new file mode 100644 index 000000000000000..bdaeaba8f5060a2 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/indexer.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 { IndexProgress, IndexRequest, IndexStats, RepositoryUri } from '../../model'; + +export type ProgressReporter = (progress: IndexProgress) => void; + +export interface Indexer { + start(ProgressReporter?: ProgressReporter, checkpointReq?: IndexRequest): Promise; + cancel(): void; +} + +export interface IndexerFactory { + create(repoUri: RepositoryUri, revision: string): Promise; +} diff --git a/x-pack/plugins/code/server/indexer/lsp_incremental_indexer.ts b/x-pack/plugins/code/server/indexer/lsp_incremental_indexer.ts new file mode 100644 index 000000000000000..2cf88fc10c78b74 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/lsp_incremental_indexer.ts @@ -0,0 +1,285 @@ +/* + * 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 util from 'util'; + +import { ProgressReporter } from '.'; +import { Diff, DiffKind } from '../../common/git_diff'; +import { toCanonicalUrl } from '../../common/uri_util'; +import { + Document, + IndexStats, + IndexStatsKey, + LspIncIndexRequest, + RepositoryUri, +} from '../../model'; +import { GitOperations } from '../git_operations'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { LspService } from '../lsp/lsp_service'; +import { ServerOptions } from '../server_options'; +import { detectLanguage } from '../utils/detect_language'; +import { LspIndexer } from './lsp_indexer'; +import { DocumentIndexName, ReferenceIndexName, SymbolIndexName } from './schema'; + +export class LspIncrementalIndexer extends LspIndexer { + protected type: string = 'lsp_inc'; + private diff: Diff | undefined = undefined; + + constructor( + protected readonly repoUri: RepositoryUri, + // The latest revision to be indexed + protected readonly revision: string, + // The already indexed revision + protected readonly originRevision: string, + protected readonly lspService: LspService, + protected readonly options: ServerOptions, + protected readonly client: EsClient, + protected readonly log: Logger + ) { + super(repoUri, revision, lspService, options, client, log); + } + + public async start(progressReporter?: ProgressReporter, checkpointReq?: LspIncIndexRequest) { + return await super.start(progressReporter, checkpointReq); + } + + // If the current checkpoint is valid. Otherwise, ignore the checkpoint + protected validateCheckpoint(checkpointReq?: LspIncIndexRequest): boolean { + return ( + checkpointReq !== undefined && + checkpointReq.revision === this.revision && + checkpointReq.originRevision === this.originRevision + ); + } + + // If it's necessary to refresh (create and reset) all the related indices + protected needRefreshIndices(_: LspIncIndexRequest): boolean { + return false; + } + + protected ifCheckpointMet(req: LspIncIndexRequest, checkpointReq: LspIncIndexRequest): boolean { + // Assume for the same revision pair, the order of the files we iterate the diff is definite + // everytime. + return ( + req.filePath === checkpointReq.filePath && + req.revision === checkpointReq.revision && + req.originRevision === checkpointReq.originRevision && + req.kind === checkpointReq.kind + ); + } + + protected async prepareIndexCreationRequests() { + // We don't need to create new indices for incremental indexing. + return []; + } + + protected async processRequest(request: LspIncIndexRequest): Promise { + const stats: IndexStats = new Map() + .set(IndexStatsKey.Symbol, 0) + .set(IndexStatsKey.Reference, 0) + .set(IndexStatsKey.File, 0) + .set(IndexStatsKey.SymbolDeleted, 0) + .set(IndexStatsKey.ReferenceDeleted, 0) + .set(IndexStatsKey.FileDeleted, 0); + if (this.isCancelled()) { + this.log.debug(`Incremental indexer is cancelled. Skip.`); + return stats; + } + + const { kind } = request; + + this.log.debug(`Index ${kind} request ${JSON.stringify(request, null, 2)}`); + switch (kind) { + case DiffKind.ADDED: { + await this.handleAddedRequest(request, stats); + break; + } + case DiffKind.DELETED: { + await this.handleDeletedRequest(request, stats); + break; + } + case DiffKind.MODIFIED: { + await this.handleModifiedRequest(request, stats); + break; + } + case DiffKind.RENAMED: { + await this.handleRenamedRequest(request, stats); + break; + } + default: { + this.log.debug( + `Unsupported diff kind ${kind} for incremental indexing. Skip this request.` + ); + } + } + + return stats; + } + + protected async *getIndexRequestIterator(): AsyncIterableIterator { + try { + const { workspaceRepo } = await this.lspService.workspaceHandler.openWorkspace( + this.repoUri, + 'head' + ); + const workspaceDir = workspaceRepo.workdir(); + if (this.diff) { + for (const f of this.diff.files) { + yield { + repoUri: this.repoUri, + localRepoPath: workspaceDir, + filePath: f.path, + originPath: f.originPath, + revision: this.revision, + kind: f.kind, + originRevision: this.originRevision, + }; + } + } + } catch (error) { + this.log.error(`Get lsp incremental index requests count error.`); + this.log.error(error); + throw error; + } + } + + protected async getIndexRequestCount(): Promise { + try { + const gitOperator = new GitOperations(this.options.repoPath); + // cache here to avoid pulling the diff twice. + this.diff = await gitOperator.getDiff(this.repoUri, this.originRevision, this.revision); + return this.diff.files.length; + } catch (error) { + this.log.error(`Get lsp incremental index requests count error.`); + this.log.error(error); + throw error; + } + } + + protected async cleanIndex() { + this.log.info('Do not need to clean index for incremental indexing.'); + } + + private async handleAddedRequest(request: LspIncIndexRequest, stats: IndexStats) { + const { repoUri, revision, filePath, localRepoPath } = request; + + const lspDocUri = toCanonicalUrl({ repoUri, revision, file: filePath, schema: 'git:' }); + const symbolNames = new Set(); + + try { + const response = await this.lspService.sendRequest('textDocument/full', { + textDocument: { + uri: lspDocUri, + }, + reference: this.options.enableGlobalReference, + }); + + if (response && response.result.length > 0) { + const { symbols, references } = response.result[0]; + for (const symbol of symbols) { + await this.batchIndexHelper.index(SymbolIndexName(repoUri), symbol); + symbolNames.add(symbol.symbolInformation.name); + } + stats.set(IndexStatsKey.Symbol, symbols.length); + + for (const ref of references) { + await this.batchIndexHelper.index(ReferenceIndexName(repoUri), ref); + } + stats.set(IndexStatsKey.Reference, references.length); + } else { + this.log.debug(`Empty response from lsp server. Skip symbols and references indexing.`); + } + } catch (error) { + this.log.error(`Index symbols or references error. Skip to file indexing.`); + this.log.error(error); + } + + const localFilePath = `${localRepoPath}${filePath}`; + const lstat = util.promisify(fs.lstat); + const stat = await lstat(localFilePath); + + const readLink = util.promisify(fs.readlink); + const readFile = util.promisify(fs.readFile); + const content = stat.isSymbolicLink() + ? await readLink(localFilePath, 'utf8') + : await readFile(localFilePath, 'utf8'); + + const language = await detectLanguage(filePath, Buffer.from(content)); + const body: Document = { + repoUri, + path: filePath, + content, + language, + qnames: Array.from(symbolNames), + }; + await this.batchIndexHelper.index(DocumentIndexName(repoUri), body); + stats.set(IndexStatsKey.File, 1); + } + + private async handleDeletedRequest(request: LspIncIndexRequest, stats: IndexStats) { + const { revision, filePath, repoUri } = request; + + // Delete the document with the exact file path. TODO: add stats + const docRes = await this.client.deleteByQuery({ + index: DocumentIndexName(repoUri), + body: { + query: { + term: { + 'path.hierarchy': filePath, + }, + }, + }, + }); + if (docRes) { + stats.set(IndexStatsKey.FileDeleted, docRes.deleted); + } + + const lspDocUri = toCanonicalUrl({ repoUri, revision, file: filePath, schema: 'git:' }); + + // Delete all symbols within this file + const symbolRes = await this.client.deleteByQuery({ + index: SymbolIndexName(repoUri), + body: { + query: { + term: { + 'symbolInformation.location.uri': lspDocUri, + }, + }, + }, + }); + if (symbolRes) { + stats.set(IndexStatsKey.SymbolDeleted, symbolRes.deleted); + } + + // TODO: When references is enabled. Clean up the references as well. + } + + private async handleModifiedRequest(request: LspIncIndexRequest, stats: IndexStats) { + const { kind, originRevision, originPath, repoUri, localRepoPath } = request; + + // 1. first delete all related indexed data + await this.handleDeletedRequest( + { + repoUri, + localRepoPath, + revision: originRevision, + filePath: originPath ? originPath : '', + kind, + originRevision, + }, + stats + ); + // 2. index data with modified version + await this.handleAddedRequest(request, stats); + } + + private async handleRenamedRequest(request: LspIncIndexRequest, stats: IndexStats) { + // Do the same as modified file + await this.handleModifiedRequest(request, stats); + } +} diff --git a/x-pack/plugins/code/server/indexer/lsp_indexer.ts b/x-pack/plugins/code/server/indexer/lsp_indexer.ts new file mode 100644 index 000000000000000..2bae330e953f1ee --- /dev/null +++ b/x-pack/plugins/code/server/indexer/lsp_indexer.ts @@ -0,0 +1,242 @@ +/* + * 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 util from 'util'; + +import { ProgressReporter } from '.'; +import { toCanonicalUrl } from '../../common/uri_util'; +import { Document, IndexStats, IndexStatsKey, LspIndexRequest, RepositoryUri } from '../../model'; +import { GitOperations } from '../git_operations'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { LspService } from '../lsp/lsp_service'; +import { ServerOptions } from '../server_options'; +import { detectLanguage, detectLanguageByFilename } from '../utils/detect_language'; +import { AbstractIndexer } from './abstract_indexer'; +import { BatchIndexHelper } from './batch_index_helper'; +import { + getDocumentIndexCreationRequest, + getReferenceIndexCreationRequest, + getSymbolIndexCreationRequest, +} from './index_creation_request'; +import { ALL_RESERVED, DocumentIndexName, ReferenceIndexName, SymbolIndexName } from './schema'; + +export class LspIndexer extends AbstractIndexer { + protected type: string = 'lsp'; + protected batchIndexHelper: BatchIndexHelper; + + constructor( + protected readonly repoUri: RepositoryUri, + protected readonly revision: string, + protected readonly lspService: LspService, + protected readonly options: ServerOptions, + protected readonly client: EsClient, + protected readonly log: Logger + ) { + super(repoUri, revision, client, log); + + this.batchIndexHelper = new BatchIndexHelper(client, log); + } + + public async start(progressReporter?: ProgressReporter, checkpointReq?: LspIndexRequest) { + try { + return await super.start(progressReporter, checkpointReq); + } finally { + if (!this.isCancelled()) { + // Flush all the index request still in the cache for bulk index. + this.batchIndexHelper.flush(); + } + } + } + + public cancel() { + this.batchIndexHelper.cancel(); + super.cancel(); + } + + // If the current checkpoint is valid + protected validateCheckpoint(checkpointReq?: LspIndexRequest): boolean { + return checkpointReq !== undefined && checkpointReq.revision === this.revision; + } + + // If it's necessary to refresh (create and reset) all the related indices + protected needRefreshIndices(checkpointReq?: LspIndexRequest): boolean { + // If it's not resumed from a checkpoint, then try to refresh all the indices. + return !this.validateCheckpoint(checkpointReq); + } + + protected ifCheckpointMet(req: LspIndexRequest, checkpointReq: LspIndexRequest): boolean { + // Assume for the same revision, the order of the files we iterate the repository is definite + // everytime. + return req.filePath === checkpointReq.filePath && req.revision === checkpointReq.revision; + } + + protected async prepareIndexCreationRequests() { + return [ + getDocumentIndexCreationRequest(this.repoUri), + getReferenceIndexCreationRequest(this.repoUri), + getSymbolIndexCreationRequest(this.repoUri), + ]; + } + + protected async *getIndexRequestIterator(): AsyncIterableIterator { + try { + const { + workspaceRepo, + workspaceRevision, + } = await this.lspService.workspaceHandler.openWorkspace(this.repoUri, 'head'); + const workspaceDir = workspaceRepo.workdir(); + const gitOperator = new GitOperations(this.options.repoPath); + const fileIterator = await gitOperator.iterateRepo(this.repoUri, 'head'); + for await (const file of fileIterator) { + const filePath = file.path!; + const lang = detectLanguageByFilename(filePath); + // filter file by language + if (lang && this.lspService.supportLanguage(lang)) { + const req: LspIndexRequest = { + repoUri: this.repoUri, + localRepoPath: workspaceDir, + filePath, + revision: workspaceRevision, + }; + yield req; + } + } + } catch (error) { + this.log.error(`Prepare lsp indexing requests error.`); + this.log.error(error); + throw error; + } + } + + protected async getIndexRequestCount(): Promise { + try { + const gitOperator = new GitOperations(this.options.repoPath); + return await gitOperator.countRepoFiles(this.repoUri, 'head'); + } catch (error) { + this.log.error(`Get lsp index requests count error.`); + this.log.error(error); + throw error; + } + } + + protected async cleanIndex() { + // Clean up all the symbol documents in the symbol index + try { + await this.client.deleteByQuery({ + index: SymbolIndexName(this.repoUri), + body: { + query: { + match_all: {}, + }, + }, + }); + this.log.info(`Clean up symbols for ${this.repoUri} done.`); + } catch (error) { + this.log.error(`Clean up symbols for ${this.repoUri} error.`); + this.log.error(error); + } + + // Clean up all the reference documents in the reference index + try { + await this.client.deleteByQuery({ + index: ReferenceIndexName(this.repoUri), + body: { + query: { + match_all: {}, + }, + }, + }); + this.log.info(`Clean up references for ${this.repoUri} done.`); + } catch (error) { + this.log.error(`Clean up references for ${this.repoUri} error.`); + this.log.error(error); + } + + // Clean up all the document documents in the document index but keep the repository document. + try { + await this.client.deleteByQuery({ + index: DocumentIndexName(this.repoUri), + body: { + query: { + bool: { + must_not: ALL_RESERVED.map((field: string) => ({ + exists: { + field, + }, + })), + }, + }, + }, + }); + this.log.info(`Clean up documents for ${this.repoUri} done.`); + } catch (error) { + this.log.error(`Clean up documents for ${this.repoUri} error.`); + this.log.error(error); + } + } + + protected async processRequest(request: LspIndexRequest): Promise { + const stats: IndexStats = new Map() + .set(IndexStatsKey.Symbol, 0) + .set(IndexStatsKey.Reference, 0) + .set(IndexStatsKey.File, 0); + const { repoUri, revision, filePath, localRepoPath } = request; + const lspDocUri = toCanonicalUrl({ repoUri, revision, file: filePath, schema: 'git:' }); + const symbolNames = new Set(); + + try { + const response = await this.lspService.sendRequest('textDocument/full', { + textDocument: { + uri: lspDocUri, + }, + reference: this.options.enableGlobalReference, + }); + + if (response && response.result && response.result.length > 0 && response.result[0]) { + const { symbols, references } = response.result[0]; + for (const symbol of symbols) { + await this.batchIndexHelper.index(SymbolIndexName(repoUri), symbol); + symbolNames.add(symbol.symbolInformation.name); + } + stats.set(IndexStatsKey.Symbol, symbols.length); + + for (const ref of references) { + await this.batchIndexHelper.index(ReferenceIndexName(repoUri), ref); + } + stats.set(IndexStatsKey.Reference, references.length); + } else { + this.log.debug(`Empty response from lsp server. Skip symbols and references indexing.`); + } + } catch (error) { + this.log.error(`Index symbols or references error. Skip to file indexing.`); + this.log.error(error); + } + + const localFilePath = `${localRepoPath}${filePath}`; + const lstat = util.promisify(fs.lstat); + const stat = await lstat(localFilePath); + + const readLink = util.promisify(fs.readlink); + const readFile = util.promisify(fs.readFile); + const content = stat.isSymbolicLink() + ? await readLink(localFilePath, 'utf8') + : await readFile(localFilePath, 'utf8'); + + const language = await detectLanguage(filePath, Buffer.from(content)); + const body: Document = { + repoUri, + path: filePath, + content, + language, + qnames: Array.from(symbolNames), + }; + await this.batchIndexHelper.index(DocumentIndexName(repoUri), body); + stats.set(IndexStatsKey.File, 1); + return stats; + } +} diff --git a/x-pack/plugins/code/server/indexer/lsp_indexer_factory.ts b/x-pack/plugins/code/server/indexer/lsp_indexer_factory.ts new file mode 100644 index 000000000000000..8b1b5688f9bc30a --- /dev/null +++ b/x-pack/plugins/code/server/indexer/lsp_indexer_factory.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 { Indexer, IndexerFactory, LspIncrementalIndexer, LspIndexer } from '.'; +import { RepositoryUri } from '../../model'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { LspService } from '../lsp/lsp_service'; +import { RepositoryObjectClient } from '../search'; +import { ServerOptions } from '../server_options'; + +export class LspIndexerFactory implements IndexerFactory { + private objectClient: RepositoryObjectClient; + + constructor( + protected readonly lspService: LspService, + protected readonly options: ServerOptions, + protected readonly client: EsClient, + protected readonly log: Logger + ) { + this.objectClient = new RepositoryObjectClient(this.client); + } + + public async create(repoUri: RepositoryUri, revision: string): Promise { + try { + const repo = await this.objectClient.getRepository(repoUri); + const indexedRevision = repo.indexedRevision; + if (indexedRevision) { + this.log.info(`Create indexer to index ${repoUri} from ${indexedRevision} to ${revision}`); + // Create the indexer to index only the diff between these 2 revisions. + return new LspIncrementalIndexer( + repoUri, + revision, + indexedRevision, + this.lspService, + this.options, + this.client, + this.log + ); + } else { + this.log.info(`Create indexer to index ${repoUri} at ${revision}`); + // Create the indexer to index the entire repository. + return new LspIndexer( + repoUri, + revision, + this.lspService, + this.options, + this.client, + this.log + ); + } + } catch (error) { + this.log.error(`Create indexer error for ${repoUri}.`); + this.log.error(error); + return undefined; + } + } +} diff --git a/x-pack/plugins/code/server/indexer/repository_index_initializer.test.ts b/x-pack/plugins/code/server/indexer/repository_index_initializer.test.ts new file mode 100644 index 000000000000000..7ef010451caa3a0 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/repository_index_initializer.test.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 sinon from 'sinon'; + +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { RepositoryIndexInitializer } from './repository_index_initializer'; + +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esClient = { + indices: { + existsAlias: emptyAsyncFunc, + create: emptyAsyncFunc, + putAlias: emptyAsyncFunc, + }, +}; + +afterEach(() => { + sinon.restore(); +}); + +test('Initialize the repository index', async () => { + // Setup the esClient spies + const existsAliasSpy = sinon.fake.returns(false); + const createSpy = sinon.spy(); + const putAliasSpy = sinon.spy(); + esClient.indices.existsAlias = existsAliasSpy; + esClient.indices.create = createSpy; + esClient.indices.putAlias = putAliasSpy; + + const initializer = new RepositoryIndexInitializer( + 'mockuri', + 'mockrevision', + esClient as EsClient, + log + ); + await initializer.init(); + + // Expect these indices functions to be called only once. + expect(existsAliasSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledOnce).toBeTruthy(); + expect(putAliasSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledAfter(existsAliasSpy)).toBeTruthy(); + expect(putAliasSpy.calledAfter(createSpy)).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/indexer/repository_index_initializer.ts b/x-pack/plugins/code/server/indexer/repository_index_initializer.ts new file mode 100644 index 000000000000000..1f8cc571de55622 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/repository_index_initializer.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 { RepositoryUri } from '../../model'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { AbstractIndexer } from './abstract_indexer'; +import { IndexCreationRequest } from './index_creation_request'; +import { RepositoryAnalysisSettings, RepositoryIndexName, RepositorySchema } from './schema'; + +// Inherit AbstractIndexer's index creation logics. This is not an actual indexer. +export class RepositoryIndexInitializer extends AbstractIndexer { + protected type: string = 'repository'; + + constructor( + protected readonly repoUri: RepositoryUri, + protected readonly revision: string, + protected readonly client: EsClient, + protected readonly log: Logger + ) { + super(repoUri, revision, client, log); + } + + public async prepareIndexCreationRequests() { + const creationReq: IndexCreationRequest = { + index: RepositoryIndexName(this.repoUri), + settings: { + ...RepositoryAnalysisSettings, + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, + schema: RepositorySchema, + }; + return [creationReq]; + } + + public async init() { + const res = await this.prepareIndex(); + if (!res) { + this.log.error(`Initialize repository index failed.`); + } + return; + } +} diff --git a/x-pack/plugins/code/server/indexer/repository_index_initializer_factory.ts b/x-pack/plugins/code/server/indexer/repository_index_initializer_factory.ts new file mode 100644 index 000000000000000..5797b06b1f79c3c --- /dev/null +++ b/x-pack/plugins/code/server/indexer/repository_index_initializer_factory.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 { Indexer, IndexerFactory, RepositoryIndexInitializer } from '.'; +import { RepositoryUri } from '../../model'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; + +export class RepositoryIndexInitializerFactory implements IndexerFactory { + constructor(protected readonly client: EsClient, protected readonly log: Logger) {} + + public async create(repoUri: RepositoryUri, revision: string): Promise { + return new RepositoryIndexInitializer(repoUri, revision, this.client, this.log); + } +} diff --git a/x-pack/plugins/code/server/indexer/schema/document.ts b/x-pack/plugins/code/server/indexer/schema/document.ts new file mode 100644 index 000000000000000..d1582c5bca85925 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/schema/document.ts @@ -0,0 +1,255 @@ +/* + * 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 { RepositoryUtils } from '../../../common/repository_utils'; +import { RepositoryUri } from '../../../model'; + +// Coorespond to model/search/Document +export const DocumentSchema = { + repoUri: { + type: 'keyword', + }, + path: { + type: 'text', + analyzer: 'path_analyzer', + fields: { + hierarchy: { + type: 'text', + analyzer: 'path_hierarchy_analyzer', + }, + }, + }, + content: { + type: 'text', + analyzer: 'content_analyzer', + }, + qnames: { + type: 'text', + analyzer: 'qname_path_hierarchy_analyzer', + }, + language: { + type: 'keyword', + }, + sha1: { + type: 'text', + index: false, + norms: false, + }, + // Repository object resides in this document index. + // There is always a single Repository object in this index. + repository: { + properties: { + uri: { + type: 'text', + }, + url: { + type: 'text', + index: false, + }, + name: { + type: 'text', + }, + org: { + type: 'text', + }, + defaultBranch: { + type: 'keyword', + }, + revision: { + type: 'keyword', + }, + indexedRevision: { + type: 'keyword', + }, + }, + }, + repository_config: { + properties: { + uri: { + type: 'text', + }, + disableGo: { + type: 'boolean', + }, + disableJava: { + type: 'boolean', + }, + disableTypescript: { + type: 'boolean', + }, + }, + }, + repository_random_path: { + type: 'keyword', + }, + // A single Repository Git Status object resides in this document index. + repository_git_status: { + properties: { + uri: { + type: 'text', + }, + progress: { + type: 'integer', + }, + timestamp: { + type: 'date', + }, + revision: { + type: 'keyword', + }, + errorMessage: { + type: 'text', + }, + cloneProgress: { + properties: { + isCloned: { + type: 'boolean', + }, + receivedObjects: { + type: 'integer', + }, + indexedObjects: { + type: 'integer', + }, + totalObjects: { + type: 'integer', + }, + localObjects: { + type: 'integer', + }, + totalDeltas: { + type: 'integer', + }, + indexedDeltas: { + type: 'integer', + }, + receivedBytes: { + type: 'integer', + }, + }, + }, + }, + }, + // A single Repository Delete Status object resides in this document index. + repository_delete_status: { + properties: { + uri: { + type: 'text', + }, + progress: { + type: 'integer', + }, + timestamp: { + type: 'date', + }, + revision: { + type: 'keyword', + }, + }, + }, + // A single Repository LSP Index Status object resides in this document index. + repository_lsp_index_status: { + properties: { + uri: { + type: 'text', + }, + progress: { + type: 'integer', + }, + timestamp: { + type: 'date', + }, + revision: { + type: 'keyword', + }, + indexProgress: { + properties: { + type: { + type: 'keyword', + }, + total: { + type: 'integer', + }, + success: { + type: 'integer', + }, + fail: { + type: 'integer', + }, + percentage: { + type: 'integer', + }, + checkpoint: { + type: 'object', + }, + }, + }, + }, + }, +}; + +export const DocumentAnalysisSettings = { + analysis: { + analyzer: { + content_analyzer: { + tokenizer: 'standard', + char_filter: ['content_char_filter'], + filter: ['lowercase'], + }, + lowercase_analyzer: { + type: 'custom', + filter: ['lowercase'], + tokenizer: 'keyword', + }, + path_analyzer: { + type: 'custom', + filter: ['lowercase'], + tokenizer: 'path_tokenizer', + }, + path_hierarchy_analyzer: { + type: 'custom', + tokenizer: 'path_hierarchy_tokenizer', + filter: ['lowercase'], + }, + qname_path_hierarchy_analyzer: { + type: 'custom', + tokenizer: 'qname_path_hierarchy_tokenizer', + filter: ['lowercase'], + }, + }, + char_filter: { + content_char_filter: { + type: 'pattern_replace', + pattern: '[.]', + replacement: ' ', + }, + }, + tokenizer: { + path_tokenizer: { + type: 'pattern', + pattern: '[\\\\./]', + }, + qname_path_hierarchy_tokenizer: { + type: 'path_hierarchy', + delimiter: '.', + reverse: 'true', + }, + path_hierarchy_tokenizer: { + type: 'path_hierarchy', + delimiter: '/', + reverse: 'true', + }, + }, + }, +}; + +export const DocumentIndexNamePrefix = `.code-document`; +export const DocumentIndexName = (repoUri: RepositoryUri) => { + return `${DocumentIndexNamePrefix}-${RepositoryUtils.normalizeRepoUriToIndexName(repoUri)}`; +}; +export const DocumentSearchIndexWithScope = (repoScope: RepositoryUri[]) => { + return repoScope.map((repoUri: RepositoryUri) => `${DocumentIndexName(repoUri)}*`).join(','); +}; diff --git a/x-pack/plugins/code/server/indexer/schema/index.ts b/x-pack/plugins/code/server/indexer/schema/index.ts new file mode 100644 index 000000000000000..bba1c7cadf34833 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/schema/index.ts @@ -0,0 +1,10 @@ +/* + * 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 './document'; +export * from './reference'; +export * from './repository'; +export * from './symbol'; diff --git a/x-pack/plugins/code/server/indexer/schema/reference.ts b/x-pack/plugins/code/server/indexer/schema/reference.ts new file mode 100644 index 000000000000000..6006e3849c7d01e --- /dev/null +++ b/x-pack/plugins/code/server/indexer/schema/reference.ts @@ -0,0 +1,43 @@ +/* + * 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 { RepositoryUtils } from '../../../common/repository_utils'; +import { RepositoryUri } from '../../../model'; + +export const ReferenceSchema = { + category: { + type: 'keyword', + }, + location: { + properties: { + uri: { + type: 'text', + }, + }, + }, + symbol: { + properties: { + name: { + type: 'text', + }, + kind: { + type: 'keyword', + }, + location: { + properties: { + uri: { + type: 'text', + }, + }, + }, + }, + }, +}; + +export const ReferenceIndexNamePrefix = `.code-reference`; +export const ReferenceIndexName = (repoUri: RepositoryUri) => { + return `${ReferenceIndexNamePrefix}-${RepositoryUtils.normalizeRepoUriToIndexName(repoUri)}`; +}; diff --git a/x-pack/plugins/code/server/indexer/schema/repository.ts b/x-pack/plugins/code/server/indexer/schema/repository.ts new file mode 100644 index 000000000000000..6cc0898e3783363 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/schema/repository.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 { + DocumentAnalysisSettings, + DocumentIndexName, + DocumentIndexNamePrefix, + DocumentSchema, + DocumentSearchIndexWithScope, +} from './document'; + +export const RepositorySchema = DocumentSchema; +export const RepositoryAnalysisSettings = DocumentAnalysisSettings; +export const RepositoryIndexNamePrefix = DocumentIndexNamePrefix; +export const RepositoryIndexName = DocumentIndexName; +export const RepositorySearchIndexWithScope = DocumentSearchIndexWithScope; + +// The field name of repository object nested in the Document index. +export const RepositoryReservedField = 'repository'; +// The field name of repository git status object nested in the Document index. +export const RepositoryGitStatusReservedField = 'repository_git_status'; +// The field name of repository delete status object nested in the Document index. +export const RepositoryDeleteStatusReservedField = 'repository_delete_status'; +// The field name of repository lsp index status object nested in the Document index. +export const RepositoryLspIndexStatusReservedField = 'repository_lsp_index_status'; +// The field name of repository config object nested in the Document index. +export const RepositoryConfigReservedField = 'repository_config'; +// The field name of repository config object nested in the Document index. +export const RepositoryRandomPathReservedField = 'repository_random_path'; + +export const ALL_RESERVED = [ + RepositoryReservedField, + RepositoryGitStatusReservedField, + RepositoryDeleteStatusReservedField, + RepositoryLspIndexStatusReservedField, + RepositoryConfigReservedField, + RepositoryRandomPathReservedField, +]; diff --git a/x-pack/plugins/code/server/indexer/schema/symbol.ts b/x-pack/plugins/code/server/indexer/schema/symbol.ts new file mode 100644 index 000000000000000..d318730f466a620 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/schema/symbol.ts @@ -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 { RepositoryUtils } from '../../../common/repository_utils'; +import { RepositoryUri } from '../../../model'; + +export const SymbolSchema = { + qname: { + type: 'text', + analyzer: 'qname_path_hierarchy_case_sensitive_analyzer', + fields: { + // Create a 'lowercased' field to match query in lowercased mode. + lowercased: { + type: 'text', + analyzer: 'qname_path_hierarchy_case_insensitive_analyzer', + }, + }, + }, + symbolInformation: { + properties: { + name: { + type: 'text', + analyzer: 'qname_path_hierarchy_case_sensitive_analyzer', + fields: { + // Create a 'lowercased' field to match query in lowercased mode. + lowercased: { + type: 'text', + analyzer: 'qname_path_hierarchy_case_insensitive_analyzer', + }, + }, + }, + kind: { + type: 'integer', + index: false, + }, + location: { + properties: { + uri: { + // Indexed now for symbols batch deleting in incremental indexing + type: 'keyword', + }, + }, + }, + }, + }, +}; + +export const SymbolAnalysisSettings = { + analysis: { + analyzer: { + qname_path_hierarchy_case_sensitive_analyzer: { + type: 'custom', + tokenizer: 'qname_path_hierarchy_tokenizer', + }, + qname_path_hierarchy_case_insensitive_analyzer: { + type: 'custom', + tokenizer: 'qname_path_hierarchy_tokenizer', + filter: ['lowercase'], + }, + }, + tokenizer: { + qname_path_hierarchy_tokenizer: { + type: 'path_hierarchy', + delimiter: '.', + reverse: 'true', + }, + }, + }, +}; + +export const SymbolIndexNamePrefix = `.code-symbol`; +export const SymbolIndexName = (repoUri: RepositoryUri) => { + return `${SymbolIndexNamePrefix}-${RepositoryUtils.normalizeRepoUriToIndexName(repoUri)}`; +}; +export const SymbolSearchIndexWithScope = (repoScope: RepositoryUri[]) => { + return repoScope.map((repoUri: RepositoryUri) => `${SymbolIndexName(repoUri)}*`).join(','); +}; diff --git a/x-pack/plugins/code/server/indexer/schema/version.json b/x-pack/plugins/code/server/indexer/schema/version.json new file mode 100644 index 000000000000000..00198abef978db3 --- /dev/null +++ b/x-pack/plugins/code/server/indexer/schema/version.json @@ -0,0 +1,3 @@ +{ + "codeIndexVersion": "1" +} \ No newline at end of file diff --git a/x-pack/plugins/code/server/init.ts b/x-pack/plugins/code/server/init.ts new file mode 100644 index 000000000000000..da70087c9a0e67e --- /dev/null +++ b/x-pack/plugins/code/server/init.ts @@ -0,0 +1,255 @@ +/* + * 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 { Server } from 'hapi'; +import fetch from 'node-fetch'; +import { i18n } from '@kbn/i18n'; +import { XPackMainPlugin } from '../../xpack_main/xpack_main'; +import { checkRepos } from './check_repos'; +import { LspIndexerFactory, RepositoryIndexInitializerFactory, tryMigrateIndices } from './indexer'; +import { EsClient, Esqueue } from './lib/esqueue'; +import { Logger } from './log'; +import { InstallManager } from './lsp/install_manager'; +import { LanguageServers, LanguageServersDeveloping } from './lsp/language_servers'; +import { LspService } from './lsp/lsp_service'; +import { CancellationSerivce, CloneWorker, DeleteWorker, IndexWorker, UpdateWorker } from './queue'; +import { RepositoryConfigController } from './repository_config_controller'; +import { RepositoryServiceFactory } from './repository_service_factory'; +import { fileRoute } from './routes/file'; +import { installRoute } from './routes/install'; +import { lspRoute, symbolByQnameRoute } from './routes/lsp'; +import { redirectRoute } from './routes/redirect'; +import { repositoryRoute } from './routes/repository'; +import { documentSearchRoute, repositorySearchRoute, symbolSearchRoute } from './routes/search'; +import { setupRoute } from './routes/setup'; +import { workspaceRoute } from './routes/workspace'; +import { IndexScheduler, UpdateScheduler } from './scheduler'; +import { CodeServerRouter } from './security'; +import { ServerOptions } from './server_options'; +import { ServerLoggerFactory } from './utils/server_logger_factory'; + +async function retryUntilAvailable( + func: () => Promise, + intervalMs: number, + retries: number = Number.MAX_VALUE +): Promise { + const value = await func(); + if (value) { + return value; + } else { + const promise = new Promise(resolve => { + const retry = () => { + func().then(v => { + if (v) { + resolve(v); + } else { + retries--; + if (retries > 0) { + setTimeout(retry, intervalMs); + } else { + resolve(v); + } + } + }); + }; + setTimeout(retry, intervalMs); + }); + return await promise; + } +} + +function getServerUuid(server: Server): Promise { + const uid = server.config().get('server.uuid') as string; + return Promise.resolve(uid); +} + +async function getCodeNodeUuid(url: string, log: Logger) { + const res = await fetch(`${url}/api/stats`, {}); + if (res.ok) { + return (await res.json()).kibana.uuid; + } + + log.info(`Access code node ${url} failed, try again later.`); + return null; +} + +export function init(server: Server, options: any) { + const log = new Logger(server); + const serverOptions = new ServerOptions(options, server.config()); + const xpackMainPlugin: XPackMainPlugin = server.plugins.xpack_main; + xpackMainPlugin.registerFeature({ + id: 'code', + name: i18n.translate('xpack.code.featureRegistry.codeFeatureName', { + defaultMessage: 'Code', + }), + icon: 'codeApp', + navLinkId: 'code', + app: ['code', 'kibana'], + catalogue: [], // TODO add catalogue here + privileges: { + all: { + api: ['code_user', 'code_admin'], + savedObject: { + all: [], + read: ['config'], + }, + ui: ['show', 'user', 'admin'], + }, + read: { + api: ['code_user'], + savedObject: { + all: [], + read: ['config'], + }, + ui: ['show', 'user'], + }, + }, + }); + + // @ts-ignore + const kbnServer = this.kbnServer; + kbnServer.ready().then(async () => { + const serverUuid = await retryUntilAvailable(() => getServerUuid(server), 50); + // enable security check in routes + const codeNodeUrl = serverOptions.codeNodeUrl; + if (codeNodeUrl) { + const codeNodeUuid = (await retryUntilAvailable( + async () => await getCodeNodeUuid(codeNodeUrl, log), + 5000 + )) as string; + if (codeNodeUuid === serverUuid) { + await initCodeNode(server, serverOptions, log); + } else { + await initNonCodeNode(codeNodeUrl, server, serverOptions, log); + } + } else { + // codeNodeUrl not set, single node mode + await initCodeNode(server, serverOptions, log); + } + }); +} + +async function initNonCodeNode( + url: string, + server: Server, + serverOptions: ServerOptions, + log: Logger +) { + log.info(`Initializing Code plugin as non-code node, redirecting all code requests to ${url}`); + redirectRoute(server, url, log); +} + +async function initCodeNode(server: Server, serverOptions: ServerOptions, log: Logger) { + log.info('Initializing Code plugin as code-node.'); + const queueIndex: string = server.config().get('xpack.code.queueIndex'); + const queueTimeout: number = server.config().get('xpack.code.queueTimeout'); + const devMode: boolean = server.config().get('env.dev'); + const adminCluster = server.plugins.elasticsearch.getCluster('admin'); + + // @ts-ignore + const esClient: EsClient = adminCluster.clusterClient.client; + const repoConfigController = new RepositoryConfigController(esClient); + + server.injectUiAppVars('code', () => ({ + enableLangserversDeveloping: devMode, + })); + // Enable the developing language servers in development mode. + if (devMode === true) { + LanguageServers.push(...LanguageServersDeveloping); + } + + const installManager = new InstallManager(server, serverOptions); + const lspService = new LspService( + '127.0.0.1', + serverOptions, + esClient, + installManager, + new ServerLoggerFactory(server), + repoConfigController + ); + // Initialize indexing factories. + const lspIndexerFactory = new LspIndexerFactory(lspService, serverOptions, esClient, log); + + const repoIndexInitializerFactory = new RepositoryIndexInitializerFactory(esClient, log); + + // Initialize queue worker cancellation service. + const cancellationService = new CancellationSerivce(); + + // Execute index version checking and try to migrate index data if necessary. + await tryMigrateIndices(esClient, log); + + // Initialize queue. + const queue = new Esqueue(queueIndex, { + client: esClient, + timeout: queueTimeout, + }); + const indexWorker = new IndexWorker( + queue, + log, + esClient, + [lspIndexerFactory], + serverOptions, + cancellationService + ).bind(); + + const repoServiceFactory: RepositoryServiceFactory = new RepositoryServiceFactory(); + + const cloneWorker = new CloneWorker( + queue, + log, + esClient, + serverOptions, + indexWorker, + repoServiceFactory + ).bind(); + const deleteWorker = new DeleteWorker( + queue, + log, + esClient, + serverOptions, + cancellationService, + lspService, + repoServiceFactory + ).bind(); + const updateWorker = new UpdateWorker( + queue, + log, + esClient, + serverOptions, + repoServiceFactory + ).bind(); + + // Initialize schedulers. + const updateScheduler = new UpdateScheduler(updateWorker, serverOptions, esClient, log); + const indexScheduler = new IndexScheduler(indexWorker, serverOptions, esClient, log); + updateScheduler.start(); + if (!serverOptions.disableIndexScheduler) { + indexScheduler.start(); + } + // check code node repos on disk + await checkRepos(cloneWorker, esClient, serverOptions, log); + + const codeServerRouter = new CodeServerRouter(server); + // Add server routes and initialize the plugin here + repositoryRoute( + codeServerRouter, + cloneWorker, + deleteWorker, + indexWorker, + repoIndexInitializerFactory, + repoConfigController, + serverOptions + ); + repositorySearchRoute(codeServerRouter, log); + documentSearchRoute(codeServerRouter, log); + symbolSearchRoute(codeServerRouter, log); + fileRoute(codeServerRouter, serverOptions); + workspaceRoute(codeServerRouter, serverOptions); + symbolByQnameRoute(codeServerRouter, log); + installRoute(codeServerRouter, lspService, installManager); + lspRoute(codeServerRouter, lspService, serverOptions); + setupRoute(codeServerRouter); +} diff --git a/x-pack/plugins/code/server/lib/esqueue/constants/default_settings.js b/x-pack/plugins/code/server/lib/esqueue/constants/default_settings.js new file mode 100644 index 000000000000000..5df580a063dd9f4 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/constants/default_settings.js @@ -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 const defaultSettings = { + DEFAULT_SETTING_TIMEOUT: 10000, + DEFAULT_SETTING_DATE_SEPARATOR: '-', + DEFAULT_SETTING_INTERVAL: 'week', + DEFAULT_SETTING_INDEX_SETTINGS: { + number_of_shards: 1, + auto_expand_replicas: '0-1', + }, +}; diff --git a/x-pack/plugins/code/server/lib/esqueue/constants/events.d.ts b/x-pack/plugins/code/server/lib/esqueue/constants/events.d.ts new file mode 100644 index 000000000000000..e160951d541836e --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/constants/events.d.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. + */ + +declare class Events { + public EVENT_QUEUE_ERROR: 'queue:error'; + public EVENT_JOB_ERROR: 'job:error'; + public EVENT_JOB_CREATED: 'job:created'; + public EVENT_JOB_CREATE_ERROR: 'job:creation error'; + public EVENT_WORKER_COMPLETE: 'worker:job complete'; + public EVENT_WORKER_RESET_PROCESSING_JOB_ERROR: 'worker:reset job processing error'; + public EVENT_WORKER_JOB_CLAIM_ERROR: 'worker:claim job error'; + public EVENT_WORKER_JOB_SEARCH_ERROR: 'worker:pending jobs error'; + public EVENT_WORKER_JOB_UPDATE_ERROR: 'worker:update job error'; + public EVENT_WORKER_JOB_FAIL: 'worker:job failed'; + public EVENT_WORKER_JOB_FAIL_ERROR: 'worker:failed job update error'; + public EVENT_WORKER_JOB_EXECUTION_ERROR: 'worker:job execution error'; + public EVENT_WORKER_JOB_TIMEOUT: 'worker:job timeout'; +} + +declare const events: Events; + +export { events }; diff --git a/x-pack/plugins/code/server/lib/esqueue/constants/events.js b/x-pack/plugins/code/server/lib/esqueue/constants/events.js new file mode 100644 index 000000000000000..126f0eb51f2c912 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/constants/events.js @@ -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 const events = { + EVENT_QUEUE_ERROR: 'queue:error', + EVENT_JOB_ERROR: 'job:error', + EVENT_JOB_CREATED: 'job:created', + EVENT_JOB_CREATE_ERROR: 'job:creation error', + EVENT_WORKER_COMPLETE: 'worker:job complete', + EVENT_WORKER_RESET_PROCESSING_JOB_ERROR: 'worker:reset job processing error', + EVENT_WORKER_JOB_CLAIM_ERROR: 'worker:claim job error', + EVENT_WORKER_JOB_SEARCH_ERROR: 'worker:pending jobs error', + EVENT_WORKER_JOB_UPDATE_ERROR: 'worker:update job error', + EVENT_WORKER_JOB_FAIL: 'worker:job failed', + EVENT_WORKER_JOB_FAIL_ERROR: 'worker:failed job update error', + EVENT_WORKER_JOB_EXECUTION_ERROR: 'worker:job execution error', + EVENT_WORKER_JOB_TIMEOUT: 'worker:job timeout', +}; diff --git a/x-pack/plugins/code/server/lib/esqueue/constants/index.js b/x-pack/plugins/code/server/lib/esqueue/constants/index.js new file mode 100644 index 000000000000000..5f9efddc82dfdbe --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/constants/index.js @@ -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. + */ + +import { events } from './events'; +import { statuses } from './statuses'; +import { defaultSettings } from './default_settings'; + +export const constants = { + ...events, + ...statuses, + ...defaultSettings +}; diff --git a/x-pack/plugins/code/server/lib/esqueue/constants/statuses.d.ts b/x-pack/plugins/code/server/lib/esqueue/constants/statuses.d.ts new file mode 100644 index 000000000000000..43b96237de27e1a --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/constants/statuses.d.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 type Status = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; diff --git a/x-pack/plugins/code/server/lib/esqueue/constants/statuses.js b/x-pack/plugins/code/server/lib/esqueue/constants/statuses.js new file mode 100644 index 000000000000000..620a567e18fe717 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/constants/statuses.js @@ -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 const statuses = { + JOB_STATUS_PENDING: 'pending', + JOB_STATUS_PROCESSING: 'processing', + JOB_STATUS_COMPLETED: 'completed', + JOB_STATUS_FAILED: 'failed', + JOB_STATUS_CANCELLED: 'cancelled', +}; diff --git a/x-pack/plugins/code/server/lib/esqueue/esqueue.d.ts b/x-pack/plugins/code/server/lib/esqueue/esqueue.d.ts new file mode 100644 index 000000000000000..def36c2f133273b --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/esqueue.d.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 { EventEmitter } from 'events'; + +import { events } from './constants/events'; +import { Job, JobOptions } from './job'; +import { AnyObject, EsClient, LogFn } from './misc'; +import { Worker, WorkerFn, WorkerOptions, WorkerOutput } from './worker'; + +export class Esqueue extends EventEmitter { + constructor( + /** + * The base name Esqueue will use for its time-based job indices in Elasticsearch. This + * will have a date string appended to it to determine the actual index name. + */ + index: string, + options: { + /** + * The Elasticsearch client EsQueue will use to query ES and manage its queue indices + */ + client: EsClient; + + /** + * A function that Esqueue will call with log messages + */ + logger?: LogFn; + + /** + * Interval that Esqueue will use when creating its time-based job indices in Elasticsearch + */ + interval?: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute'; + + /** + * Default job timeout + */ + timeout?: number; + + /** + * The value used to separate the parts of the date in index names created by Esqueue + */ + dateSeparator?: string; + + /** + * Arbitrary settings that will be merged with the default index settings EsQueue uses to + * create elastcisearch indices + */ + indexSettings?: AnyObject; + } + ); + + public addJob>(type: string, payload: P, options: JobOptions): J; + + public registerWorker>( + this: void, + type: string, + workerFn: WorkerFn, + opts?: Pick> + ): W; +} diff --git a/x-pack/plugins/code/server/lib/esqueue/esqueue.js b/x-pack/plugins/code/server/lib/esqueue/esqueue.js new file mode 100644 index 000000000000000..4320a6d781cef74 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/esqueue.js @@ -0,0 +1,82 @@ +/* + * 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. + */ + +/* + * Borrowed from https://github.com/elastic/kibana/tree/master/x-pack/plugins/reporting/server/lib/esqueue + * TODO(mengwei): need to abstract this esqueue as a common library when merging into kibana's main repo. + */ + +import { EventEmitter } from 'events'; +import { Job } from './job'; +import { Worker } from './worker'; +import { constants } from './constants'; +import { indexTimestamp } from './helpers/index_timestamp'; + +function omit(obj, keysToOmit) { + return Object.keys(obj).reduce((acc, key) => ( + keysToOmit.includes(key) ? acc : { ...acc, [key]: obj[key] } + ), {}); +} + +export class Esqueue extends EventEmitter { + constructor(index, options = {}) { + if (!index) throw new Error('Must specify an index to write to'); + + super(); + this.index = index; + this.settings = { + interval: constants.DEFAULT_SETTING_INTERVAL, + timeout: constants.DEFAULT_SETTING_TIMEOUT, + dateSeparator: constants.DEFAULT_SETTING_DATE_SEPARATOR, + ...omit(options, ['client']) + }; + this.client = options.client; + this._logger = options.logger || function () {}; + this._workers = []; + this._initTasks().catch((err) => this.emit(constants.EVENT_QUEUE_ERROR, err)); + } + + _initTasks() { + const initTasks = [ + this.client.ping(), + ]; + + return Promise.all(initTasks).catch((err) => { + this._logger(err, ['initTasks', 'error']); + throw err; + }); + } + + addJob(type, payload, opts = {}) { + const timestamp = indexTimestamp(this.settings.interval, this.settings.dateSeparator); + const index = `${this.index}-${timestamp}`; + const defaults = { + timeout: this.settings.timeout, + }; + + const options = Object.assign(defaults, opts, { + indexSettings: this.settings.indexSettings, + logger: this._logger + }); + + return new Job(this, index, type, payload, options); + } + + registerWorker(type, workerFn, opts) { + const worker = new Worker(this, type, workerFn, { ...opts, logger: this._logger }); + this._workers.push(worker); + return worker; + } + + getWorkers() { + return this._workers.map((fn) => fn); + } + + destroy() { + const workers = this._workers.filter((worker) => worker.destroy()); + this._workers = workers; + } +} diff --git a/x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.d.ts b/x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.d.ts new file mode 100644 index 000000000000000..0cdee0927a9155b --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.d.ts @@ -0,0 +1,10 @@ +/* + * 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 class CancellationToken { + public on(callback: () => void): void; + public cancel(): void; +} diff --git a/x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.js b/x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.js new file mode 100644 index 000000000000000..f874b857889f980 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/helpers/cancellation_token.js @@ -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. + */ + +export class CancellationToken { + constructor() { + this.isCancelled = false; + this._callbacks = []; + } + + on = (callback) => { + if (typeof callback !== 'function') { + throw new Error('Expected callback to be a function'); + } + + if (this.isCancelled) { + callback(); + return; + } + + this._callbacks.push(callback); + }; + + cancel = () => { + this.isCancelled = true; + this._callbacks.forEach(callback => callback()); + }; +} diff --git a/x-pack/plugins/code/server/lib/esqueue/helpers/create_index.js b/x-pack/plugins/code/server/lib/esqueue/helpers/create_index.js new file mode 100644 index 000000000000000..48af625311bea5d --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/helpers/create_index.js @@ -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 { constants } from '../constants'; + +const schema = { + meta: { + // We are indexing these properties with both text and keyword fields because that's what will be auto generated + // when an index already exists. This schema is only used when a reporting index doesn't exist. This way existing + // reporting indexes and new reporting indexes will look the same and the data can be queried in the same + // manner. + properties: { + /** + * Type of object that is triggering this report. Should be either search, visualization or dashboard. + * Used for phone home stats only. + */ + objectType: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + /** + * Can be either preserve_layout, print or none (in the case of csv export). + * Used for phone home stats only. + */ + layout: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + } + }, + jobtype: { type: 'keyword' }, + payload: { type: 'object', enabled: false }, + priority: { type: 'byte' }, + timeout: { type: 'long' }, + process_expiration: { type: 'date' }, + created_by: { type: 'keyword' }, + created_at: { type: 'date' }, + started_at: { type: 'date' }, + completed_at: { type: 'date' }, + attempts: { type: 'short' }, + max_attempts: { type: 'short' }, + status: { type: 'keyword' }, + output: { + type: 'object', + properties: { + content_type: { type: 'keyword' }, + content: { type: 'object', enabled: false } + } + } +}; + +export function createIndex(client, indexName, + indexSettings = { }) { + const body = { + settings: { + ...constants.DEFAULT_SETTING_INDEX_SETTINGS, + ...indexSettings + }, + mappings: { + properties: schema + } + }; + + return client.indices.exists({ + index: indexName, + }) + .then((exists) => { + if (!exists) { + return client.indices.create({ + index: indexName, + body: body + }) + .then(() => true); + } + return exists; + }); +} diff --git a/x-pack/plugins/code/server/lib/esqueue/helpers/errors.js b/x-pack/plugins/code/server/lib/esqueue/helpers/errors.js new file mode 100644 index 000000000000000..b8be62d5ad4554a --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/helpers/errors.js @@ -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 function WorkerTimeoutError(message, props = {}) { + this.name = 'WorkerTimeoutError'; + this.message = message; + this.timeout = props.timeout; + this.jobId = props.jobId; + + if ('captureStackTrace' in Error) Error.captureStackTrace(this, WorkerTimeoutError); + else this.stack = (new Error()).stack; +} +WorkerTimeoutError.prototype = Object.create(Error.prototype); + +export function UnspecifiedWorkerError(message, props = {}) { + this.name = 'UnspecifiedWorkerError'; + this.message = message; + this.jobId = props.jobId; + + if ('captureStackTrace' in Error) Error.captureStackTrace(this, UnspecifiedWorkerError); + else this.stack = (new Error()).stack; +} +UnspecifiedWorkerError.prototype = Object.create(Error.prototype); diff --git a/x-pack/plugins/code/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/plugins/code/server/lib/esqueue/helpers/index_timestamp.js new file mode 100644 index 000000000000000..c03be000306503f --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/helpers/index_timestamp.js @@ -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 moment from 'moment'; + +export const intervals = [ + 'year', + 'month', + 'week', + 'day', + 'hour', + 'minute' +]; + +export function indexTimestamp(intervalStr, separator = '-') { + if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); + + const index = intervals.indexOf(intervalStr); + if (index === -1) throw new Error('Invalid index interval: ', intervalStr); + + const m = moment(); + m.startOf(intervalStr); + + let dateString; + switch (intervalStr) { + case 'year': + dateString = 'YYYY'; + break; + case 'month': + dateString = `YYYY${separator}MM`; + break; + case 'hour': + dateString = `YYYY${separator}MM${separator}DD${separator}HH`; + break; + case 'minute': + dateString = `YYYY${separator}MM${separator}DD${separator}HH${separator}mm`; + break; + default: + dateString = `YYYY${separator}MM${separator}DD`; + } + + return m.format(dateString); +} diff --git a/x-pack/plugins/code/server/lib/esqueue/helpers/poller.js b/x-pack/plugins/code/server/lib/esqueue/helpers/poller.js new file mode 100644 index 000000000000000..1f33f02a0fb256e --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/helpers/poller.js @@ -0,0 +1,81 @@ +/* + * 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. + */ + +/* + * Borrowed from https://github.com/elastic/kibana/blob/master/x-pack/common/poller.js + */ + +import _ from 'lodash'; + +export class Poller { + + constructor(options) { + this.functionToPoll = options.functionToPoll; // Must return a Promise + this.successFunction = options.successFunction || _.noop; + this.errorFunction = options.errorFunction || _.noop; + this.pollFrequencyInMillis = options.pollFrequencyInMillis; + this.trailing = options.trailing || false; + this.continuePollingOnError = options.continuePollingOnError || false; + this.pollFrequencyErrorMultiplier = options.pollFrequencyErrorMultiplier || 1; + this._timeoutId = null; + this._isRunning = false; + } + + getPollFrequency() { + return this.pollFrequencyInMillis; + } + + _poll() { + return this.functionToPoll() + .then(this.successFunction) + .then(() => { + if (!this._isRunning) { + return; + } + + this._timeoutId = setTimeout(this._poll.bind(this), this.pollFrequencyInMillis); + }) + .catch(e => { + this.errorFunction(e); + if (!this._isRunning) { + return; + } + + if (this.continuePollingOnError) { + this._timeoutId = setTimeout(this._poll.bind(this), this.pollFrequencyInMillis * this.pollFrequencyErrorMultiplier); + } else { + this.stop(); + } + }); + } + + start() { + if (this._isRunning) { + return; + } + + this._isRunning = true; + if (this.trailing) { + this._timeoutId = setTimeout(this._poll.bind(this), this.pollFrequencyInMillis); + } else { + this._poll(); + } + } + + stop() { + if (!this._isRunning) { + return; + } + + this._isRunning = false; + clearTimeout(this._timeoutId); + this._timeoutId = null; + } + + isRunning() { + return this._isRunning; + } +} diff --git a/x-pack/plugins/code/server/lib/esqueue/index.d.ts b/x-pack/plugins/code/server/lib/esqueue/index.d.ts new file mode 100644 index 000000000000000..d276e06b3a139d0 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/index.d.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 { events } from './constants/events'; +export { CancellationToken } from './helpers/cancellation_token'; +export { Job } from './job'; +export { Worker, WorkerOutput } from './worker'; +export { Esqueue } from './esqueue'; +export { AnyObject, EsClient } from './misc'; diff --git a/x-pack/plugins/code/server/lib/esqueue/index.js b/x-pack/plugins/code/server/lib/esqueue/index.js new file mode 100644 index 000000000000000..54c7502630715d2 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/index.js @@ -0,0 +1,9 @@ +/* + * 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 { CancellationToken } from './helpers/cancellation_token'; +export { events } from './constants/events'; +export { Esqueue } from './esqueue'; diff --git a/x-pack/plugins/code/server/lib/esqueue/job.d.ts b/x-pack/plugins/code/server/lib/esqueue/job.d.ts new file mode 100644 index 000000000000000..d554f2832a13692 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/job.d.ts @@ -0,0 +1,102 @@ +/* + * 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 { EventEmitter } from 'events'; + +import { events } from './constants/events'; +import { Status } from './constants/statuses'; +import { Esqueue } from './esqueue'; +import { CancellationToken } from './helpers/cancellation_token'; +import { AnyObject, EsClient, LogFn } from './misc'; + +export interface JobOptions { + client?: EsClient; + indexSettings?: string; + created_by?: string; + timeout?: number; + max_attempts?: number; + priority?: number; + headers?: { + [key: string]: string; + }; + logger?: LogFn; +} + +type OptionalPropType = P extends keyof T ? T[P] : void; + +export class Job

extends EventEmitter { + public queue: Esqueue; + public client: EsClient; + public id: string; + public index: string; + public jobtype: string; + public payload: P; + public created_by: string | false; // tslint:disable-line variable-name + public timeout: number; + public maxAttempts: number; + public priority: number; + public indexSettings: AnyObject; + public ready: Promise; + + constructor(queue: Esqueue, index: string, type: string, payload: P, options?: JobOptions); + + /** + * Read the job document out of elasticsearch, includes its current + * status and posisble result. + */ + public get(): Promise<{ + // merged in get() method + index: string; + id: string; + type: string; + version: number; + + // from doc._source + jobtype: string; + meta: { + objectType: OptionalPropType; + layout: OptionalPropType; + }; + payload: P; + priority: number; + created_by: string | false; + timeout: number; + process_expiration: string; // use epoch so the job query works + created_at: string; + attempts: number; + max_attempts: number; + status: Status; + }>; + + /** + * Get a plain JavaScript representation of the Job object + */ + public toJSON(): { + id: string; + index: string; + type: string; + jobtype: string; + created_by: string | false; + payload: P; + timeout: number; + max_attempts: number; + priority: number; + }; + + public on( + name: typeof events['EVENT_JOB_CREATED'], + handler: ( + info: { + id: string; + type: string; + index: string; + version: number; + } + ) => void + ): this; + public on(name: typeof events['EVENT_JOB_CREATE_ERROR'], handler: (error: Error) => void): this; + public on(name: string, ...args: any[]): this; +} diff --git a/x-pack/plugins/code/server/lib/esqueue/job.js b/x-pack/plugins/code/server/lib/esqueue/job.js new file mode 100644 index 000000000000000..4c8f5ee73b27769 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/job.js @@ -0,0 +1,134 @@ +/* + * 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 events from 'events'; + +import Puid from 'puid'; + +import { constants } from './constants'; +import { createIndex } from './helpers/create_index'; + +const puid = new Puid(); + +export class Job extends events.EventEmitter { + constructor(queue, index, type, payload, options = {}) { + if (typeof type !== 'string') throw new Error('Type must be a string'); + if (!payload || typeof payload !== 'object') throw new Error('Payload must be a plain object'); + + super(); + + this.queue = queue; + this.client = options.client || this.queue.client; + this.id = puid.generate(); + this.index = index; + this.jobtype = type; + this.payload = payload; + this.created_by = options.created_by || false; + this.timeout = options.timeout || 10000; + this.maxAttempts = options.max_attempts || 3; + this.priority = Math.max(Math.min(options.priority || 10, 20), -20); + this.indexSettings = options.indexSettings || {}; + + this.debug = (msg, err) => { + const logger = options.logger || function () {}; + const message = `${this.id} - ${msg}`; + const tags = ['job', 'debug']; + + if (err) { + logger(`${message}: ${err}`, tags); + return; + } + + logger(message, tags); + }; + + const indexParams = { + index: this.index, + id: this.id, + body: { + jobtype: this.jobtype, + meta: { + // We are copying these values out of payload because these fields are indexed and can be aggregated on + // for tracking stats, while payload contents are not. + objectType: payload.type, + layout: payload.layout ? payload.layout.id : 'none', + }, + payload: this.payload, + priority: this.priority, + created_by: this.created_by, + timeout: this.timeout, + process_expiration: new Date(0), // use epoch so the job query works + created_at: new Date(), + attempts: 0, + max_attempts: this.maxAttempts, + status: constants.JOB_STATUS_PENDING, + } + }; + + if (options.headers) { + indexParams.headers = options.headers; + } + + this.ready = createIndex(this.client, this.index, this.indexSettings) + .then(() => this.client.index(indexParams)) + .then((doc) => { + this.document = { + id: doc._id, + type: doc._type, + index: doc._index, + version: doc._version, + }; + this.debug(`Job created in index ${this.index}`); + + return this.client.indices.refresh({ + index: this.index + }).then(() => { + this.debug(`Job index refreshed ${this.index}`); + this.emit(constants.EVENT_JOB_CREATED, this.document); + }); + }) + .catch((err) => { + this.debug('Job creation failed', err); + this.emit(constants.EVENT_JOB_CREATE_ERROR, err); + }); + } + + emit(name, ...args) { + super.emit(name, ...args); + this.queue.emit(name, ...args); + } + + get() { + return this.ready + .then(() => { + return this.client.get({ + index: this.index, + id: this.id + }); + }) + .then((doc) => { + return Object.assign(doc._source, { + index: doc._index, + id: doc._id, + type: doc._type, + version: doc._version, + }); + }); + } + + toJSON() { + return { + id: this.id, + index: this.index, + jobtype: this.jobtype, + created_by: this.created_by, + payload: this.payload, + timeout: this.timeout, + max_attempts: this.maxAttempts, + priority: this.priority + }; + } +} diff --git a/x-pack/plugins/code/server/lib/esqueue/misc.d.ts b/x-pack/plugins/code/server/lib/esqueue/misc.d.ts new file mode 100644 index 000000000000000..19df2dbc9e7b6f7 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/misc.d.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. + */ + +export interface AnyObject { + [key: string]: any; +} + +export interface EsClient { + indices: { + exists(params: AnyObject): Promise; + create(params: AnyObject): Promise; + refresh(params: AnyObject): Promise; + delete(params: AnyObject): Promise; + + existsAlias(params: AnyObject): Promise; + getAlias(params: AnyObject): Promise; + putAlias(params: AnyObject): Promise; + deleteAlias(params: AnyObject): Promise; + updateAliases(params: AnyObject): Promise; + + getMapping(params: AnyObject): Promise; + }; + + ping(): Promise; + bulk(params: AnyObject): Promise; + index(params: AnyObject): Promise; + get(params: AnyObject): Promise; + update(params: AnyObject): Promise; + reindex(params: AnyObject): Promise; + search(params: AnyObject): Promise; + delete(params: AnyObject): Promise; + deleteByQuery(params: AnyObject): Promise; +} + +export type LogFn = (msg: string | Error, tags: string[]) => void; diff --git a/x-pack/plugins/code/server/lib/esqueue/worker.d.ts b/x-pack/plugins/code/server/lib/esqueue/worker.d.ts new file mode 100644 index 000000000000000..7fe6cc630af9f3f --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/worker.d.ts @@ -0,0 +1,98 @@ +/* + * 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 { EventEmitter } from 'events'; + +import { events } from './constants/events'; +import { Esqueue } from './esqueue'; +import { CancellationToken } from './helpers/cancellation_token'; +import { Job } from './job'; +import { AnyObject, EsClient, LogFn } from './misc'; + +type Handler = (arg: A) => void; + +interface JobInfo { + index: string; + type: string; + id: string; +} + +interface WorkerInfo { + id: string; + index: string; + jobType: string; +} + +interface ErrorInfo { + error: Error; + worker: WorkerInfo; + job: JobInfo; +} + +interface ErrorInfoNoJob { + error: Error; + worker: WorkerInfo; +} + +type WorkerOutput = { + content: T; + content_type: string; + max_size_reached?: any; +} | void; + +export type WorkerFn = ( + payload: P, + cancellationToken: CancellationToken +) => Promise; + +export interface WorkerOptions { + interval: number; + capacity: number; + intervalErrorMultiplier: number; + client?: EsClient; + size?: number; + logger?: LogFn; +} + +export class Worker extends EventEmitter { + public id: string; + public queue: Esqueue; + public client: EsClient; + public jobType: string; + public workerFn: WorkerFn; + public checkSize: number; + constructor(queue: Esqueue, type: string, workerFn: WorkerFn, opts: WorkerOptions); + + public destroy(): void; + + /** + * Get a plain JavaScript object describing this worker + */ + public toJSON(): { + id: string; + index: string; + jobType: string; + }; + + public on( + name: typeof events['EVENT_WORKER_COMPLETE'], + h: Handler<{ + job: { + index: string; + type: string; + id: string; + }; + output: O; + }> + ): this; + public on(name: typeof events['EVENT_WORKER_JOB_CLAIM_ERROR'], h: Handler): this; + public on(name: typeof events['EVENT_WORKER_JOB_SEARCH_ERROR'], h: Handler): this; + public on( + name: typeof events['EVENT_WORKER_JOB_FAIL'], + h: Handler<{ job: JobInfo; worker: WorkerInfo; output: O }> + ): this; + public on(name: string, ...args: any[]): this; +} diff --git a/x-pack/plugins/code/server/lib/esqueue/worker.js b/x-pack/plugins/code/server/lib/esqueue/worker.js new file mode 100644 index 000000000000000..e8d63b2906accf1 --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/worker.js @@ -0,0 +1,480 @@ +/* + * 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 events from 'events'; +import Puid from 'puid'; +import moment from 'moment'; +import { constants } from './constants'; +import { WorkerTimeoutError, UnspecifiedWorkerError } from './helpers/errors'; +import { CancellationToken } from './helpers/cancellation_token'; +import { Poller } from './helpers/poller'; + +const puid = new Puid(); + +function formatJobObject(job) { + return { + index: job._index, + type: job._type, + id: job._id, + // Expose the payload of the job even when the job failed/timeout + ...job._source.payload, + }; +} + +export class Worker extends events.EventEmitter { + constructor(queue, type, workerFn, opts) { + if (typeof type !== 'string') throw new Error('type must be a string'); + if (typeof workerFn !== 'function') throw new Error('workerFn must be a function'); + if (typeof opts !== 'object') throw new Error('opts must be an object'); + if (typeof opts.interval !== 'number') throw new Error('opts.interval must be a number'); + if (typeof opts.intervalErrorMultiplier !== 'number') throw new Error('opts.intervalErrorMultiplier must be a number'); + + super(); + + this.id = puid.generate(); + this.queue = queue; + this.client = opts.client || this.queue.client; + this.jobtype = type; + this.workerFn = workerFn; + this.checkSize = opts.size || 10; + this.capacity = opts.capacity || 2; + this.processingJobCount = 0; + + this.debug = (msg, err) => { + const logger = opts.logger || function () {}; + + const message = `${this.id} - ${msg}`; + const tags = ['worker', 'debug']; + + if (err) { + logger(`${message}: ${err.stack ? err.stack : err }`, tags); + return; + } + + logger(message, tags); + }; + + this._running = true; + this.debug(`Created worker for job type ${this.jobtype}`); + + this._poller = new Poller({ + functionToPoll: () => { + this._processPendingJobs(); + // Return an empty promise so that the processing jobs won't block the next poll. + return Promise.resolve(); + }, + pollFrequencyInMillis: opts.interval, + trailing: true, + continuePollingOnError: true, + pollFrequencyErrorMultiplier: opts.intervalErrorMultiplier, + }); + // Reset all the existing processing jobs of this particular type. + this._resetProcessingJobs(); + this._startJobPolling(); + } + + destroy() { + this._running = false; + this._stopJobPolling(); + } + + toJSON() { + return { + id: this.id, + index: this.queue.index, + jobType: this.jobType, + }; + } + + emit(name, ...args) { + super.emit(name, ...args); + this.queue.emit(name, ...args); + } + + _formatErrorParams(err, job) { + const response = { + error: err, + worker: this.toJSON(), + }; + + if (job) response.job = formatJobObject(job); + return response; + } + + _claimJob(job) { + const m = moment(); + const startTime = m.toISOString(); + const expirationTime = m.add(job._source.timeout).toISOString(); + const attempts = job._source.attempts + 1; + + if (attempts > job._source.max_attempts) { + const msg = (!job._source.output) ? `Max attempts reached (${job._source.max_attempts})` : false; + return this._failJob(job, msg) + .then(() => false); + } + + const doc = { + attempts: attempts, + started_at: startTime, + process_expiration: expirationTime, + status: constants.JOB_STATUS_PROCESSING, + }; + + return this.client.update({ + index: job._index, + type: job._type, + id: job._id, + version: job._version, + body: { doc } + }) + .then((response) => { + const updatedJob = { + ...job, + ...response + }; + updatedJob._source = { + ...job._source, + ...doc + }; + return updatedJob; + }) + .catch((err) => { + if (err.statusCode === 409) return true; + this.debug(`_claimJob failed on job ${job._id}`, err); + this.emit(constants.EVENT_WORKER_JOB_CLAIM_ERROR, this._formatErrorParams(err, job)); + return false; + }); + } + + _failJob(job, output = false) { + this.debug(`Failing job ${job._id}`); + + const completedTime = moment().toISOString(); + const docOutput = this._formatOutput(output); + const doc = { + status: constants.JOB_STATUS_FAILED, + completed_at: completedTime, + output: docOutput + }; + + this.emit(constants.EVENT_WORKER_JOB_FAIL, { + job: formatJobObject(job), + worker: this.toJSON(), + output: docOutput, + }); + + return this.client.update({ + index: job._index, + type: job._type, + id: job._id, + version: job._version, + body: { doc } + }) + .then(() => true) + .catch((err) => { + if (err.statusCode === 409) return true; + this.debug(`_failJob failed to update job ${job._id}`, err); + this.emit(constants.EVENT_WORKER_FAIL_UPDATE_ERROR, this._formatErrorParams(err, job)); + return false; + }); + } + + _cancelJob(job) { + this.debug(`Cancelling job ${job._id}`); + + const completedTime = moment().toISOString(); + const doc = { + status: constants.JOB_STATUS_CANCELLED, + completed_at: completedTime, + }; + + return this.client.update({ + index: job._index, + type: job._type, + id: job._id, + version: job._version, + body: { doc } + }) + .then(() => true) + .catch((err) => { + if (err.statusCode === 409) return true; + this.debug(`_cancelJob failed to update job ${job._id}`, err); + this.emit(constants.EVENT_WORKER_FAIL_UPDATE_ERROR, this._formatErrorParams(err, job)); + return false; + }); + } + + _formatOutput(output) { + const unknownMime = false; + const defaultOutput = null; + const docOutput = {}; + + if (typeof output === 'object' && output.content) { + docOutput.content = output.content; + docOutput.content_type = output.content_type || unknownMime; + docOutput.max_size_reached = output.max_size_reached; + } else { + docOutput.content = output || defaultOutput; + docOutput.content_type = unknownMime; + } + + return docOutput; + } + + _performJob(job) { + this.debug(`Starting job ${job._id}`); + + const workerOutput = new Promise((resolve, reject) => { + // run the worker's workerFn + let isResolved = false; + const cancellationToken = new CancellationToken(); + cancellationToken.on(() => { + this._cancelJob(job); + }); + this.processingJobCount += 1; + Promise.resolve(this.workerFn.call(null, job._source.payload, cancellationToken)) + .then((res) => { + isResolved = true; + this.processingJobCount -= 1; + resolve(res); + }) + .catch((err) => { + isResolved = true; + this.processingJobCount -= 1; + reject(err); + }); + + // fail if workerFn doesn't finish before timeout + setTimeout(() => { + if (isResolved) return; + + cancellationToken.cancel(); + this.processingJobCount -= 1; + this.debug(`Timeout processing job ${job._id}`); + reject(new WorkerTimeoutError(`Worker timed out, timeout = ${job._source.timeout}`, { + timeout: job._source.timeout, + jobId: job._id, + })); + }, job._source.timeout); + }); + + return workerOutput.then((output) => { + // job execution was successful + this.debug(`Completed job ${job._id}`); + + const completedTime = moment().toISOString(); + const docOutput = this._formatOutput(output); + + const doc = { + status: constants.JOB_STATUS_COMPLETED, + completed_at: completedTime, + output: docOutput + }; + + return this.client.update({ + index: job._index, + type: job._type, + id: job._id, + version: job._version, + body: { doc } + }) + .then(() => { + const eventOutput = { + job: formatJobObject(job), + output: docOutput, + }; + + this.emit(constants.EVENT_WORKER_COMPLETE, eventOutput); + }) + .catch((err) => { + if (err.statusCode === 409) return false; + this.debug(`Failure saving job output ${job._id}`, err); + this.emit(constants.EVENT_WORKER_JOB_UPDATE_ERROR, this._formatErrorParams(err, job)); + }); + }, (jobErr) => { + if (!jobErr) { + jobErr = new UnspecifiedWorkerError('Unspecified worker error', { + jobId: job._id, + }); + } + + // job execution failed + if (jobErr.name === 'WorkerTimeoutError') { + this.debug(`Timeout on job ${job._id}`); + this.emit(constants.EVENT_WORKER_JOB_TIMEOUT, this._formatErrorParams(jobErr, job)); + return; + + // append the jobId to the error + } else { + try { + Object.assign(jobErr, { jobId: job._id }); + } catch (e) { + // do nothing if jobId can not be appended + } + } + + this.debug(`Failure occurred on job ${job._id}`, jobErr); + this.emit(constants.EVENT_WORKER_JOB_EXECUTION_ERROR, this._formatErrorParams(jobErr, job)); + return this._failJob(job, (jobErr.toString) ? jobErr.toString() : false); + }); + } + + _startJobPolling() { + if (!this._running) { + return; + } + + this._poller.start(); + } + + _stopJobPolling() { + this._poller.stop(); + } + + _processPendingJobs() { + return this._getPendingJobs() + .then((jobs) => { + return this._claimPendingJobs(jobs); + }); + } + + _claimPendingJobs(jobs) { + if (!jobs || jobs.length === 0) return; + + let claimed = 0; + + return jobs.reduce((chain, job) => { + return chain.then((claimedJobs) => { + // Apply capacity control to make sure there won't be more jobs processing than the capacity. + if (claimed === (this.capacity - this.processingJobCount)) return claimedJobs; + + return this._claimJob(job) + .then((claimResult) => { + if (claimResult !== false) { + claimed += 1; + claimedJobs.push(claimResult); + return claimedJobs; + } + }); + }); + }, Promise.resolve([])) + .then((claimedJobs) => { + if (!claimedJobs || claimedJobs.length === 0) { + this.debug(`All ${jobs.length} jobs already claimed`); + return; + } + this.debug(`Claimed ${claimedJobs.size} jobs`); + return Promise.all(claimedJobs.map((job) => { + return this._performJob(job); + })); + }) + .catch((err) => { + this.debug('Error claiming jobs', err); + }); + } + + _resetProcessingJobs() { + const nowTime = moment().toISOString(); + const query = { + query: { + bool: { + filter: { + bool: { + minimum_should_match: 1, + must: { term: { jobtype: this.jobtype } }, + should: [ + { + bool: { + must: [ + // Conditioned on the 'processing' jobs which have not + // expired yet. + { term: { status: 'processing' } }, + { range: { process_expiration: { gt: nowTime } } } + ], + }, + }, + ], + } + } + } + }, + script: { + source: `ctx._source.status = "${constants.JOB_STATUS_PENDING}"`, + lang: 'painless', + } + }; + + return this.client.updateByQuery({ + index: `${this.queue.index}-*`, + version: true, + body: query + }) + .then((results) => { + return results.updated; + }) + .catch((err) => { + this.debug('job querying failed', err); + this.emit(constants.EVENT_WORKER_RESET_PROCESSING_JOB_ERROR, this._formatErrorParams(err)); + throw err; + }); + } + + _getPendingJobs() { + const nowTime = moment().toISOString(); + const query = { + _source: { + excludes: [ 'output.content' ] + }, + query: { + bool: { + filter: { + bool: { + minimum_should_match: 1, + must: { term: { jobtype: this.jobtype } }, + should: [ + { term: { status: 'pending' } }, + { + bool: { + must: [ + { term: { status: 'processing' } }, + { range: { process_expiration: { lte: nowTime } } } + ], + }, + }, + ], + } + } + } + }, + sort: [ + { priority: { order: 'asc' } }, + { created_at: { order: 'asc' } } + ], + size: this.checkSize + }; + + return this.client.search({ + index: `${this.queue.index}-*`, + version: true, + body: query + }) + .then((results) => { + const jobs = results.hits.hits; + if (jobs.length > 0) { + this.debug(`${jobs.length} outstanding jobs returned`); + } + return jobs; + }) + .catch((err) => { + // ignore missing indices errors + if (err && err.status === 404) return []; + + this.debug('job querying failed', err); + this.emit(constants.EVENT_WORKER_JOB_SEARCH_ERROR, this._formatErrorParams(err)); + throw err; + }); + } +} diff --git a/x-pack/plugins/code/server/lib/esqueue/yarn.lock b/x-pack/plugins/code/server/lib/esqueue/yarn.lock new file mode 100644 index 000000000000000..9f8ae5a32aca44e --- /dev/null +++ b/x-pack/plugins/code/server/lib/esqueue/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +moment@^2.20.1: + version "2.22.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" + integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y= + +puid@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/puid/-/puid-1.0.5.tgz#8d387bf05fb239c5e6f45902c49470084009638c" + integrity sha1-jTh78F+yOcXm9FkCxJRwCEAJY4w= diff --git a/x-pack/plugins/code/server/log.ts b/x-pack/plugins/code/server/log.ts new file mode 100644 index 000000000000000..29ba9fd46dee0b3 --- /dev/null +++ b/x-pack/plugins/code/server/log.ts @@ -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 Hapi from 'hapi'; +import { inspect } from 'util'; +import { Logger as VsLogger } from 'vscode-jsonrpc'; + +export class Logger implements VsLogger { + constructor(private server: Hapi.Server, private baseTags: string[] = ['code']) {} + + public info(msg: string | any) { + if (typeof msg !== 'string') { + msg = inspect(msg, { + colors: process.stdout.isTTY, + }); + } + + this.server.log([...this.baseTags, 'info'], msg); + } + + public error(msg: string | any) { + if (msg instanceof Error) { + msg = msg.stack; + } + + if (typeof msg !== 'string') { + msg = inspect(msg, { + colors: process.stdout.isTTY, + }); + } + + this.server.log([...this.baseTags, 'error'], msg); + } + + public log(message: string): void { + this.info(message); + } + + public debug(msg: string | any) { + if (typeof msg !== 'string') { + msg = inspect(msg, { + colors: process.stdout.isTTY, + }); + } + + this.server.log([...this.baseTags, 'debug'], msg); + } + + public warn(msg: string | any): void { + if (msg instanceof Error) { + msg = msg.stack; + } + + if (typeof msg !== 'string') { + msg = inspect(msg, { + colors: process.stdout.isTTY, + }); + } + + this.server.log([...this.baseTags, 'warning'], msg); + } + + // Log subprocess stdout + public stdout(msg: string | any) { + if (typeof msg !== 'string') { + msg = inspect(msg, { + colors: process.stdout.isTTY, + }); + } + + this.server.log([...this.baseTags, 'debug', 'stdout'], msg); + } + + // Log subprocess stderr + public stderr(msg: string | any) { + if (typeof msg !== 'string') { + msg = inspect(msg, { + colors: process.stdout.isTTY, + }); + } + + this.server.log([...this.baseTags, 'error', 'stderr'], msg); + } +} diff --git a/x-pack/plugins/code/server/lsp/controller.test.ts b/x-pack/plugins/code/server/lsp/controller.test.ts new file mode 100644 index 000000000000000..d2da6de4a154724 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/controller.test.ts @@ -0,0 +1,148 @@ +/* + * 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 mkdirp from 'mkdirp'; +import * as os from 'os'; +import path from 'path'; +import rimraf from 'rimraf'; +import sinon from 'sinon'; +import { LanguageServerStatus } from '../../common/language_server'; +import { LspRequest } from '../../model'; +import { RepositoryConfigController } from '../repository_config_controller'; +import { ServerOptions } from '../server_options'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { LanguageServerController } from './controller'; +import { InstallManager } from './install_manager'; +import { ILanguageServerLauncher } from './language_server_launcher'; +import { JAVA, LanguageServerDefinition, TYPESCRIPT } from './language_servers'; +import { ILanguageServerHandler } from './proxy'; + +const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code_test')); +const workspaceDir = path.join(baseDir, 'workspace'); + +// @ts-ignore +const options: ServerOptions = sinon.createStubInstance(ServerOptions); +// @ts-ignore +options.lsp = { detach: false }; +// @ts-ignore +options.maxWorkspace = 2; + +const installManager = sinon.createStubInstance(InstallManager); +// @ts-ignore +installManager.status = (def: LanguageServerDefinition) => { + return LanguageServerStatus.READY; +}; + +const repoConfigController = sinon.createStubInstance(RepositoryConfigController); +// @ts-ignore +repoConfigController.isLanguageDisabled = (uri: string, lang: string) => { + return Promise.resolve(false); +}; + +const launcherSpy = sinon.stub(); + +class LauncherStub implements ILanguageServerLauncher { + public get running(): boolean { + return launcherSpy.called; + } + + public launch( + builtinWorkspace: boolean, + maxWorkspace: number, + installationPath?: string + ): Promise { + return Promise.resolve(launcherSpy(builtinWorkspace, maxWorkspace, installationPath)); + } +} + +TYPESCRIPT.launcher = LauncherStub; +JAVA.launcher = LauncherStub; + +let controller: typeof LanguageServerController; + +beforeAll(() => { + mkdirp.sync(workspaceDir); +}); +beforeEach(async () => { + sinon.reset(); + const handler: ILanguageServerHandler = { + handleRequest(request: LspRequest): any { + return {}; + }, + exit(): any { + return {}; + }, + unloadWorkspace(_: string): any { + return {}; + }, + }; + launcherSpy.returns(handler); + controller = new LanguageServerController( + options, + '127.0.0.1', + // @ts-ignore + installManager, + new ConsoleLoggerFactory(), + repoConfigController + ); +}); +afterAll(() => { + rimraf.sync(baseDir); +}); + +function mockRequest(repo: string, file: string) { + const repoPath = path.join(workspaceDir, repo); + mkdirp.sync(repoPath); + return { + method: 'request', + params: [], + workspacePath: repoPath, + timeoutForInitializeMs: 100, + resolvedFilePath: path.join(repoPath, file), + }; +} + +test('controller should launch a lang server', async () => { + const request = mockRequest('repo1', 'test.ts'); + // @ts-ignore + await controller.handleRequest(request); + expect(launcherSpy.calledOnce).toBeTruthy(); +}); + +test('java-lang-server support should only be launched exactly once', async () => { + const request1 = mockRequest('repo1', 'Test.java'); + const request2 = mockRequest('repo2', 'Test.java'); + // @ts-ignore + const p1 = controller.handleRequest(request1); + // @ts-ignore + const p2 = controller.handleRequest(request2); + await Promise.all([p1, p2]); + expect(launcherSpy.calledOnce).toBeTruthy(); +}); + +test('should launch 2 ts-lang-server for different repo', async () => { + const request1 = mockRequest('repo1', 'test.ts'); + const request2 = mockRequest('repo2', 'test.ts'); + // @ts-ignore + const p1 = controller.handleRequest(request1); + // @ts-ignore + const p2 = controller.handleRequest(request2); + await Promise.all([p1, p2]); + expect(launcherSpy.calledTwice).toBeTruthy(); +}); + +test('should only exactly 1 ts-lang-server for the same repo', async () => { + const request1 = mockRequest('repo1', 'test.ts'); + const request2 = mockRequest('repo1', 'test.ts'); + // @ts-ignore + const p1 = controller.handleRequest(request1); + // @ts-ignore + const p2 = controller.handleRequest(request2); + await Promise.all([p1, p2]); + expect(launcherSpy.calledOnce).toBeTruthy(); + expect(launcherSpy.calledTwice).toBe(false); +}); diff --git a/x-pack/plugins/code/server/lsp/controller.ts b/x-pack/plugins/code/server/lsp/controller.ts new file mode 100644 index 000000000000000..2b891d2ffb73bf4 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/controller.ts @@ -0,0 +1,263 @@ +/* + * 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 { ResponseError } from 'vscode-jsonrpc'; +import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { LanguageServerStatus } from '../../common/language_server'; +import { + LanguageDisabled, + LanguageServerNotInstalled, + UnknownErrorCode, + UnknownFileLanguage, +} from '../../common/lsp_error_codes'; +import { LspRequest } from '../../model'; +import { Logger } from '../log'; +import { RepositoryConfigController } from '../repository_config_controller'; +import { ServerOptions } from '../server_options'; +import { detectLanguage } from '../utils/detect_language'; +import { LoggerFactory } from '../utils/log_factory'; +import { InstallManager } from './install_manager'; +import { ILanguageServerLauncher } from './language_server_launcher'; +import { LanguageServerDefinition, LanguageServers } from './language_servers'; +import { ILanguageServerHandler } from './proxy'; + +export interface LanguageServerHandlerMap { + [workspaceUri: string]: Promise; +} + +interface LanguageServerData { + definition: LanguageServerDefinition; + builtinWorkspaceFolders: boolean; + maxWorkspace: number; + languages: string[]; + launcher: ILanguageServerLauncher; + languageServerHandlers?: Promise | LanguageServerHandlerMap; +} + +/** + * Manage different LSP servers and forward request to different LSP using LanguageServerProxy, currently + * we just use forward request to all the LSP servers we are running. + */ +export class LanguageServerController implements ILanguageServerHandler { + // a list of support language servers + private readonly languageServers: LanguageServerData[]; + // a { lang -> server } map from above list + private readonly languageServerMap: { [lang: string]: LanguageServerData }; + private log: Logger; + + constructor( + readonly options: ServerOptions, + readonly targetHost: string, + readonly installManager: InstallManager, + readonly loggerFactory: LoggerFactory, + readonly repoConfigController: RepositoryConfigController + ) { + this.log = loggerFactory.getLogger([]); + this.languageServers = LanguageServers.map(def => ({ + definition: def, + builtinWorkspaceFolders: def.builtinWorkspaceFolders, + languages: def.languages, + maxWorkspace: options.maxWorkspace, + launcher: new def.launcher(this.targetHost, options, loggerFactory), + })); + this.languageServerMap = this.languageServers.reduce( + (map, ls) => { + ls.languages.forEach(lang => (map[lang] = ls)); + map[ls.definition.name] = ls; + return map; + }, + {} as { [lang: string]: LanguageServerData } + ); + } + + public async handleRequest(request: LspRequest) { + const file = request.resolvedFilePath; + if (file) { + // #todo add test for this + const lang = await detectLanguage(file.replace('file://', '')); + if (await this.repoConfigController.isLanguageDisabled(request.documentUri!, lang)) { + return Promise.reject( + new ResponseError(LanguageDisabled, `language disabled for the file`) + ); + } + return this.dispatchRequest(lang, request); + } else { + return Promise.reject( + new ResponseError(UnknownErrorCode, `can't detect language without a file`) + ); + } + } + + public async dispatchRequest(lang: string, request: LspRequest): Promise { + if (lang) { + const ls = this.findLanguageServer(lang); + if (ls.builtinWorkspaceFolders) { + if (!ls.languageServerHandlers && !ls.launcher.running) { + ls.languageServerHandlers = ls.launcher.launch( + ls.builtinWorkspaceFolders, + ls.maxWorkspace, + this.installManager.installationPath(ls.definition) + ); + } + const handler = ls.languageServerHandlers as Promise; + return (await handler).handleRequest(request); + } else { + const handler = await this.findOrCreateHandler(ls, request); + handler.lastAccess = Date.now(); + return handler.handleRequest(request); + } + } else { + return Promise.reject( + new ResponseError( + UnknownFileLanguage, + `can't detect language from file:${request.resolvedFilePath}` + ) + ); + } + } + + /** + * shutdown all language servers + */ + public async exit() { + for (const ls of this.languageServers) { + if (ls.languageServerHandlers) { + if (ls.builtinWorkspaceFolders) { + if (ls.languageServerHandlers) { + const h = await (ls.languageServerHandlers as Promise); + await h.exit(); + } + } else { + const handlers = ls.languageServerHandlers as LanguageServerHandlerMap; + for (const handlerPromise of Object.values(handlers)) { + const handler = await handlerPromise; + await handler.exit(); + } + } + } + } + } + + public async launchServers() { + for (const ls of this.languageServers) { + const installed = this.installManager.status(ls.definition) === LanguageServerStatus.READY; + // for those language server has builtin workspace support, we can launch them during kibana startup + if (installed && ls.builtinWorkspaceFolders) { + try { + ls.languageServerHandlers = ls.launcher.launch( + true, + ls.maxWorkspace, + this.installManager.installationPath(ls.definition)! + ); + } catch (e) { + this.log.error(e); + } + } + } + } + + public async unloadWorkspace(workspaceDir: string) { + for (const languageServer of this.languageServers) { + if (languageServer.languageServerHandlers) { + if (languageServer.builtinWorkspaceFolders) { + const handler = await (languageServer.languageServerHandlers as Promise< + ILanguageServerHandler + >); + await handler.unloadWorkspace(workspaceDir); + } else { + const handlers = languageServer.languageServerHandlers as LanguageServerHandlerMap; + const realPath = fs.realpathSync(workspaceDir); + const handler = handlers[realPath]; + if (handler) { + await (await handler).unloadWorkspace(realPath); + delete handlers[realPath]; + } + } + } + } + } + + public status(lang: string): LanguageServerStatus { + const ls = this.languageServerMap[lang]; + const status = this.installManager.status(ls.definition); + // installed, but is it running? + if (status === LanguageServerStatus.READY) { + if (ls.launcher.running) { + return LanguageServerStatus.RUNNING; + } + } + return status; + } + + public supportLanguage(lang: string) { + return this.languageServerMap[lang] !== undefined; + } + + private async findOrCreateHandler( + languageServer: LanguageServerData, + request: LspRequest + ): Promise { + let handlers: LanguageServerHandlerMap; + if (languageServer.languageServerHandlers) { + handlers = languageServer.languageServerHandlers as LanguageServerHandlerMap; + } else { + handlers = languageServer.languageServerHandlers = {}; + } + if (!request.workspacePath) { + throw new ResponseError(UnknownErrorCode, `no workspace in request?`); + } + const realPath = fs.realpathSync(request.workspacePath); + let handler = handlers[realPath]; + if (handler) { + return handler; + } else { + const maxWorkspace = languageServer.maxWorkspace; + const handlerArray = Object.entries(handlers); + if (handlerArray.length < maxWorkspace) { + handler = languageServer.launcher.launch( + languageServer.builtinWorkspaceFolders, + maxWorkspace, + this.installManager.installationPath(languageServer.definition) + ); + handlers[realPath] = handler; + return handler; + } else { + let [oldestWorkspace, oldestHandler] = handlerArray[0]; + for (const e of handlerArray) { + const [ws, handlePromise] = e; + const h = await handlePromise; + const oldestAccess = (await oldestHandler).lastAccess!; + if (h.lastAccess! < oldestAccess!) { + oldestWorkspace = ws; + oldestHandler = handlePromise; + } + } + delete handlers[oldestWorkspace]; + handlers[request.workspacePath] = oldestHandler; + return oldestHandler; + } + } + } + + private findLanguageServer(lang: string) { + const ls = this.languageServerMap[lang]; + if (ls) { + if ( + !this.options.lsp.detach && + this.installManager.status(ls.definition) !== LanguageServerStatus.READY + ) { + throw new ResponseError( + LanguageServerNotInstalled, + `language server ${lang} not installed` + ); + } else { + return ls; + } + } else { + throw new ResponseError(UnknownFileLanguage, `unsupported language ${lang}`); + } + } +} diff --git a/x-pack/plugins/code/server/lsp/go_launcher.ts b/x-pack/plugins/code/server/lsp/go_launcher.ts new file mode 100644 index 000000000000000..5d5c127c6082b61 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/go_launcher.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 { ServerOptions } from '../server_options'; +import { LoggerFactory } from '../utils/log_factory'; +import { ILanguageServerLauncher } from './language_server_launcher'; +import { LanguageServerProxy } from './proxy'; +import { RequestExpander } from './request_expander'; + +export class GoLauncher implements ILanguageServerLauncher { + private isRunning: boolean = false; + constructor( + readonly targetHost: string, + readonly options: ServerOptions, + readonly loggerFactory: LoggerFactory + ) {} + public get running(): boolean { + return this.isRunning; + } + + public async launch(builtinWorkspace: boolean, maxWorkspace: number, installationPath: string) { + const port = 2091; + + const log = this.loggerFactory.getLogger(['code', `go@${this.targetHost}:${port}`]); + const proxy = new LanguageServerProxy(port, this.targetHost, log, this.options.lsp); + + log.info('Detach mode, expected langserver launch externally'); + proxy.onConnected(() => { + this.isRunning = true; + }); + proxy.onDisconnected(() => { + this.isRunning = false; + if (!proxy.isClosed) { + log.warn('language server disconnected, reconnecting'); + setTimeout(() => proxy.connect(), 1000); + } + }); + + proxy.listen(); + await proxy.connect(); + return new RequestExpander(proxy, builtinWorkspace, maxWorkspace, this.options); + } +} diff --git a/x-pack/plugins/code/server/lsp/http_message_reader.ts b/x-pack/plugins/code/server/lsp/http_message_reader.ts new file mode 100644 index 000000000000000..9c7150036b3253b --- /dev/null +++ b/x-pack/plugins/code/server/lsp/http_message_reader.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 { + AbstractMessageReader, + DataCallback, + MessageReader, +} from 'vscode-jsonrpc/lib/messageReader'; + +import { HttpRequestEmitter } from './http_request_emitter'; + +export class HttpMessageReader extends AbstractMessageReader implements MessageReader { + private httpEmitter: HttpRequestEmitter; + + public constructor(httpEmitter: HttpRequestEmitter) { + super(); + httpEmitter.on('error', (error: any) => this.fireError(error)); + httpEmitter.on('close', () => this.fireClose()); + this.httpEmitter = httpEmitter; + } + + public listen(callback: DataCallback): void { + this.httpEmitter.on('message', callback); + } +} diff --git a/x-pack/plugins/code/server/lsp/http_message_writer.ts b/x-pack/plugins/code/server/lsp/http_message_writer.ts new file mode 100644 index 000000000000000..761f470292e33f2 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/http_message_writer.ts @@ -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 { Message, ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { AbstractMessageWriter, MessageWriter } from 'vscode-jsonrpc/lib/messageWriter'; +import { Logger } from '../log'; + +import { RepliesMap } from './replies_map'; + +export class HttpMessageWriter extends AbstractMessageWriter implements MessageWriter { + private replies: RepliesMap; + private logger: Logger | undefined; + + constructor(replies: RepliesMap, logger: Logger | undefined) { + super(); + this.replies = replies; + this.logger = logger; + } + + public write(msg: Message): void { + const response = msg as ResponseMessage; + if (response.id != null) { + // this is a response + const id = response.id as number; + const reply = this.replies.get(id); + if (reply) { + this.replies.delete(id); + const [resolve, reject] = reply; + if (response.error) { + reject(response.error); + } else { + resolve(response); + } + } else { + if (this.logger) { + this.logger.error('missing reply functions for ' + id); + } + } + } else { + if (this.logger) { + this.logger.info(`ignored message ${JSON.stringify(msg)} because of no id`); + } + } + } +} diff --git a/x-pack/plugins/code/server/lsp/http_request_emitter.ts b/x-pack/plugins/code/server/lsp/http_request_emitter.ts new file mode 100644 index 000000000000000..de4589aeaa9e1de --- /dev/null +++ b/x-pack/plugins/code/server/lsp/http_request_emitter.ts @@ -0,0 +1,9 @@ +/* + * 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 events from 'events'; + +export class HttpRequestEmitter extends events.EventEmitter {} diff --git a/x-pack/plugins/code/server/lsp/install_manager.test.ts b/x-pack/plugins/code/server/lsp/install_manager.test.ts new file mode 100644 index 000000000000000..d7ea95ebbdf99a6 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/install_manager.test.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. + */ +/* eslint-disable no-console */ + +import fs from 'fs'; +import nock from 'nock'; +import os from 'os'; +import path from 'path'; +import tar from 'tar-fs'; +import URL from 'url'; +import zlib from 'zlib'; +import { LanguageServers } from './language_servers'; +import { InstallManager } from './install_manager'; +import { ServerOptions } from '../server_options'; +import rimraf from 'rimraf'; +import { LanguageServerStatus } from '../../common/language_server'; +import { Server } from 'hapi'; +import { InstallationType } from '../../common/installation'; + +const LANG_SERVER_NAME = 'Java'; +const langSrvDef = LanguageServers.find(l => l.name === LANG_SERVER_NAME)!; + +const fakeTestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'foo-')); +const fakePackageFile: string = path.join(fakeTestDir, 'fakePackage.tar.gz'); +const fakeTarDir = path.join(fakeTestDir, 'fakePackage'); +const fakeFile = 'fake.file'; +const fakeContent = 'fake content'; +const options: ServerOptions = { + langServerPath: fakeTestDir, +} as ServerOptions; + +const server = new Server(); +server.config = () => { + return { + get(key: string): any { + if (key === 'pkg.version') { + return '8.0.0'; + } + }, + has(key: string): boolean { + return key === 'pkg.version'; + }, + }; +}; + +const manager = new InstallManager(server, options); + +beforeAll(async () => { + console.log('test folder is: ' + fakeTestDir); + + fs.mkdirSync(fakeTarDir); + // create a fake tar.gz package for testing + fs.writeFileSync(path.join(fakeTarDir, fakeFile), fakeContent); + return await new Promise(resolve => { + tar + .pack(fakeTarDir) + .pipe(zlib.createGzip()) + .pipe(fs.createWriteStream(fakePackageFile)) + .on('finish', resolve); + }); +}); +beforeEach(() => { + const downloadUrl = URL.parse( + langSrvDef.downloadUrl!(langSrvDef, server.config().get('pkg.version')) + ); + nock.cleanAll(); + // mimic github's behavior, redirect to a s3 address + nock(`${downloadUrl.protocol}//${downloadUrl.host!}`) + .get(downloadUrl.path!) + .reply(302, '', { + Location: 'https://s3.amazonaws.com/file.tar.gz', + }); + nock('https://s3.amazonaws.com') + .get('/file.tar.gz') + .replyWithFile(200, fakePackageFile, { + 'Content-Length': fs.statSync(fakePackageFile).size.toString(), + 'Content-Disposition': `attachment ;filename=${path.basename(fakePackageFile)}`, + }); +}); +afterEach(() => { + const p = manager.installationPath(langSrvDef); + if (p) rimraf.sync(p); +}); +afterAll(() => { + nock.cleanAll(); + rimraf.sync(fakeTestDir); +}); + +test('it can download a package', async () => { + langSrvDef.installationType = InstallationType.Download; + const p = await manager.downloadFile(langSrvDef); + console.log('package downloaded at ' + p); + expect(fs.existsSync(p)).toBeTruthy(); + expect(fs.statSync(p).size).toBe(fs.statSync(fakePackageFile).size); +}); + +test('it can install language server', async () => { + expect(manager.status(langSrvDef)).toBe(LanguageServerStatus.NOT_INSTALLED); + langSrvDef.installationType = InstallationType.Download; + const installPromise = manager.install(langSrvDef); + expect(manager.status(langSrvDef)).toBe(LanguageServerStatus.INSTALLING); + await installPromise; + expect(manager.status(langSrvDef)).toBe(LanguageServerStatus.READY); + const installPath = manager.installationPath(langSrvDef)!; + const fakeFilePath = path.join(installPath, fakeFile); + expect(fs.existsSync(fakeFilePath)).toBeTruthy(); + expect(fs.readFileSync(fakeFilePath, 'utf8')).toBe(fakeContent); +}); + +test('install language server by plugin', async () => { + langSrvDef.installationType = InstallationType.Plugin; + expect(manager.status(langSrvDef)).toBe(LanguageServerStatus.NOT_INSTALLED); + const testDir = path.join(fakeTestDir, 'test_plugin'); + fs.mkdirSync(testDir); + const pluginName = langSrvDef.installationPluginName as string; + // @ts-ignore + server.plugins = { + [pluginName]: { + install: { + path: testDir, + }, + }, + }; + expect(manager.status(langSrvDef)).toBe(LanguageServerStatus.READY); + expect(manager.installationPath(langSrvDef)).toBe(testDir); +}); diff --git a/x-pack/plugins/code/server/lsp/install_manager.ts b/x-pack/plugins/code/server/lsp/install_manager.ts new file mode 100644 index 000000000000000..63dd27214c2bea8 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/install_manager.ts @@ -0,0 +1,207 @@ +/* + * 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 { EventEmitter } from 'events'; +import fs from 'fs'; +import { Server } from 'hapi'; +import mkdirp from 'mkdirp'; +import fetch from 'node-fetch'; +import path from 'path'; +import tar from 'tar-fs'; +import zlib from 'zlib'; +import { InstallationType, InstallEvent, InstallEventType } from '../../common/installation'; +import { LanguageServerStatus } from '../../common/language_server'; +import { ServerOptions } from '../server_options'; +import { LanguageServerDefinition } from './language_servers'; + +const DOWNLOAD_PROGRESS_WEIGHT = 0.9; + +export class InstallManager { + private eventEmitter = new EventEmitter(); + private readonly basePath: string; + private installing: Set = new Set(); + + constructor(readonly server: Server, readonly serverOptions: ServerOptions) { + this.basePath = serverOptions.langServerPath; + } + + public status(def: LanguageServerDefinition): LanguageServerStatus { + if (def.installationType === InstallationType.Embed) { + return LanguageServerStatus.READY; + } + if (def.installationType === InstallationType.Plugin) { + // @ts-ignore + const plugin = this.server.plugins[def.installationPluginName!]; + if (plugin) { + const pluginPath = plugin.install.path; + if (fs.existsSync(pluginPath)) { + return LanguageServerStatus.READY; + } + } + return LanguageServerStatus.NOT_INSTALLED; + } + if (this.installing.has(def)) { + return LanguageServerStatus.INSTALLING; + } + const installationPath = this.installationPath(def); + return fs.existsSync(installationPath!) + ? LanguageServerStatus.READY + : LanguageServerStatus.NOT_INSTALLED; + } + + public on(fn: (event: InstallEvent) => void) { + this.eventEmitter.on('event', fn); + } + + public async install(def: LanguageServerDefinition) { + if (def.installationType === InstallationType.Download) { + if (!this.installing.has(def)) { + try { + this.installing.add(def); + const packageFile = await this.downloadFile(def); + await this.unPack(packageFile, def); + this.sendEvent({ + langServerName: def.name, + eventType: InstallEventType.DONE, + progress: 1, + message: `install ${def.name} done.`, + }); + } catch (e) { + this.sendEvent({ + langServerName: def.name, + eventType: InstallEventType.FAIL, + message: `install ${def.name} failed. error: ${e.message}`, + }); + throw e; + } finally { + this.installing.delete(def); + } + } + } else { + throw new Error("can't install this language server by downloading"); + } + } + + public async downloadFile(def: LanguageServerDefinition): Promise { + const url = + typeof def.downloadUrl === 'function' + ? def.downloadUrl(def, this.getKibanaVersion()) + : def.downloadUrl; + + const res = await fetch(url!); + if (!res.ok) { + throw new Error(`Unable to download language server ${def.name} from url ${url}`); + } + + const installationPath = this.installationPath(def)!; + let filename: string; + const header = res.headers.get('Content-Disposition'); + const FILE_NAME_HEAD_PREFIX = 'filename='; + if (header && header.includes(FILE_NAME_HEAD_PREFIX)) { + filename = header.substring( + header.indexOf(FILE_NAME_HEAD_PREFIX) + FILE_NAME_HEAD_PREFIX.length + ); + } else { + filename = `${def.name}-${def.version}.tar.gz`; + } + const downloadPath = path.resolve(installationPath, '..', filename); + mkdirp.sync(installationPath); + const stream = fs.createWriteStream(downloadPath); + const total = parseInt(res.headers.get('Content-Length') || '0', 10); + let downloaded = 0; + return await new Promise((resolve, reject) => { + res + // @ts-ignore + .body!.pipe(stream) + .on('error', (error: any) => { + reject(error); + }) + .on('data', (data: Buffer) => { + downloaded += data.length; + this.sendEvent({ + langServerName: def.name, + eventType: InstallEventType.DOWNLOADING, + progress: DOWNLOAD_PROGRESS_WEIGHT * (downloaded / total), + message: `downloading ${filename}(${downloaded}/${total})`, + params: { + downloaded, + total, + }, + }); + }) + .on('finish', () => { + if (res.ok) { + resolve(downloadPath); + } else { + reject(new Error(res.statusText)); + } + }); + }); + } + + public installationPath(def: LanguageServerDefinition): string | undefined { + if (def.installationType === InstallationType.Embed) { + return def.embedPath!; + } else if (def.installationType === InstallationType.Plugin) { + // @ts-ignore + const plugin: any = this.server.plugins[def.installationPluginName]; + if (plugin) { + return plugin.install.path; + } + return undefined; + } else { + let version = def.version; + if (version) { + if (def.build) { + version += '-' + def.build; + } + } else { + version = this.getKibanaVersion(); + } + return path.join(this.basePath, def.installationFolderName || def.name, version); + } + } + + private getKibanaVersion(): string { + return this.server.config().get('pkg.version'); + } + + private async unPack(packageFile: string, def: LanguageServerDefinition) { + const dest = this.installationPath(def)!; + this.sendEvent({ + langServerName: def.name, + eventType: InstallEventType.UNPACKING, + progress: DOWNLOAD_PROGRESS_WEIGHT, + message: `unpacking ${path.basename(packageFile)}`, + }); + const ext = path.extname(packageFile); + switch (ext) { + case '.gz': + await this.unPackTarball(packageFile, dest); + break; + default: + // todo support .zip + throw new Error(`unknown extension "${ext}"`); + } + // await decompress(packageFile, '/tmp/1/1'); + } + + private sendEvent(event: InstallEvent) { + this.eventEmitter.emit('event', event); + } + + private unPackTarball(packageFile: string, dest: string) { + return new Promise((resolve, reject) => { + fs.createReadStream(packageFile) + .on('error', reject) + .pipe(zlib.createGunzip()) + .on('error', reject) + .pipe(tar.extract(dest)) + .on('error', reject) + .on('finish', resolve); + }); + } +} diff --git a/x-pack/plugins/code/server/lsp/java_launcher.ts b/x-pack/plugins/code/server/lsp/java_launcher.ts new file mode 100644 index 000000000000000..6e549ba1a8a09f2 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/java_launcher.ts @@ -0,0 +1,255 @@ +/* + * 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 { execFile, spawn } from 'child_process'; +import { existsSync } from 'fs'; +import getPort from 'get-port'; +import * as glob from 'glob'; +import { platform as getOsPlatform } from 'os'; +import path from 'path'; +import { Logger } from '../log'; +import { ServerOptions } from '../server_options'; +import { LoggerFactory } from '../utils/log_factory'; +import { ILanguageServerLauncher } from './language_server_launcher'; +import { LanguageServerProxy } from './proxy'; +import { RequestExpander } from './request_expander'; + +export class JavaLauncher implements ILanguageServerLauncher { + private isRunning: boolean = false; + constructor( + readonly targetHost: string, + readonly options: ServerOptions, + readonly loggerFactory: LoggerFactory + ) {} + public get running(): boolean { + return this.isRunning; + } + + public async launch(builtinWorkspace: boolean, maxWorkspace: number, installationPath: string) { + let port = 2090; + + if (!this.options.lsp.detach) { + port = await getPort(); + } + const log = this.loggerFactory.getLogger(['code', `java@${this.targetHost}:${port}`]); + const proxy = new LanguageServerProxy(port, this.targetHost, log, this.options.lsp); + proxy.awaitServerConnection(); + if (this.options.lsp.detach) { + // detach mode + proxy.onConnected(() => { + this.isRunning = true; + }); + proxy.onDisconnected(() => { + this.isRunning = false; + if (!proxy.isClosed) { + proxy.awaitServerConnection(); + } + }); + } else { + let child = await this.spawnJava(installationPath, port, log); + proxy.onDisconnected(async () => { + if (!proxy.isClosed) { + child.kill(); + proxy.awaitServerConnection(); + log.warn('language server disconnected, restarting it'); + child = await this.spawnJava(installationPath, port, log); + } else { + child.kill(); + } + }); + proxy.onExit(() => { + if (child) { + child.kill(); + } + }); + } + proxy.listen(); + return new Promise(resolve => { + proxy.onConnected(() => { + resolve( + new RequestExpander(proxy, builtinWorkspace, maxWorkspace, this.options, { + settings: { + 'java.import.gradle.enabled': this.options.security.enableGradleImport, + 'java.import.maven.enabled': this.options.security.enableMavenImport, + 'java.autobuild.enabled': false, + }, + }) + ); + }); + }); + } + + private async getJavaHome(installationPath: string, log: Logger) { + function findJDK(platform: string) { + const JDKFound = glob.sync(`**/jdks/*${platform}/jdk-*`, { + cwd: installationPath, + }); + if (!JDKFound.length) { + log.error('Cannot find Java Home in Bundle installation for ' + platform); + return undefined; + } + return path.resolve(installationPath, JDKFound[0]); + } + + let bundledJavaHome; + + // detect platform + const osPlatform = getOsPlatform(); + switch (osPlatform) { + case 'darwin': + bundledJavaHome = `${findJDK('osx')}/Contents/Home`; + break; + case 'win32': + bundledJavaHome = `${findJDK('windows')}`; + break; + case 'freebsd': + case 'linux': + bundledJavaHome = `${findJDK('linux')}`; + break; + default: + log.error('No Bundle JDK defined ' + osPlatform); + } + + if (this.getSystemJavaHome()) { + const javaHomePath = this.getSystemJavaHome(); + if (await this.checkJavaVersion(javaHomePath)) { + return javaHomePath; + } + } + + return bundledJavaHome; + } + + private async spawnJava(installationPath: string, port: number, log: Logger) { + const launchersFound = glob.sync('**/plugins/org.eclipse.equinox.launcher_*.jar', { + cwd: installationPath, + }); + if (!launchersFound.length) { + throw new Error('Cannot find language server jar file'); + } + + const javaHomePath = await this.getJavaHome(installationPath, log); + if (!javaHomePath) { + throw new Error('Cannot find Java Home'); + } + + const javaPath = path.resolve( + javaHomePath, + 'bin', + process.platform === 'win32' ? 'java.exe' : 'java' + ); + + const p = spawn( + javaPath, + [ + '-Declipse.application=org.elastic.jdt.ls.core.id1', + '-Dosgi.bundles.defaultStartLevel=4', + '-Declipse.product=org.elastic.jdt.ls.core.product', + '-Dlog.level=ALL', + '-noverify', + '-Xmx4G', + '-jar', + path.resolve(installationPath, launchersFound[0]), + '-configuration', + this.options.jdtConfigPath, + '-data', + this.options.jdtWorkspacePath, + ], + { + detached: false, + stdio: 'pipe', + env: { + ...process.env, + CLIENT_HOST: '127.0.0.1', + CLIENT_PORT: port.toString(), + JAVA_HOME: javaHomePath, + }, + } + ); + p.stdout.on('data', data => { + log.stdout(data.toString()); + }); + p.stderr.on('data', data => { + log.stderr(data.toString()); + }); + this.isRunning = true; + p.on('exit', () => (this.isRunning = false)); + log.info( + `Launch Java Language Server at port ${port.toString()}, pid:${ + p.pid + }, JAVA_HOME:${javaHomePath}` + ); + return p; + } + + // TODO(pcxu): run /usr/libexec/java_home to get all java homes for macOS + private getSystemJavaHome(): string { + let javaHome = process.env.JDK_HOME; + if (!javaHome) { + javaHome = process.env.JAVA_HOME; + } + if (javaHome) { + javaHome = this.expandHomeDir(javaHome); + const JAVAC_FILENAME = 'javac' + (process.platform === 'win32' ? '.exe' : ''); + if (existsSync(javaHome) && existsSync(path.resolve(javaHome, 'bin', JAVAC_FILENAME))) { + return javaHome; + } + } + return ''; + } + + private checkJavaVersion(javaHome: string): Promise { + return new Promise((resolve, reject) => { + execFile( + path.resolve(javaHome, 'bin', process.platform === 'win32' ? 'java.exe' : 'java'), + ['-version'], + {}, + (error, stdout, stderr) => { + const javaVersion = this.parseMajorVersion(stderr); + if (javaVersion < 8) { + resolve(false); + } else { + resolve(true); + } + } + ); + }); + } + + private parseMajorVersion(content: string): number { + let regexp = /version "(.*)"/g; + let match = regexp.exec(content); + if (!match) { + return 0; + } + let version = match[1]; + if (version.startsWith('1.')) { + version = version.substring(2); + } + + regexp = /\d+/g; + match = regexp.exec(version); + let javaVersion = 0; + if (match) { + javaVersion = parseInt(match[0], 10); + } + return javaVersion; + } + + private expandHomeDir(javaHome: string): string { + const homeDir = process.env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME']; + if (!javaHome) { + return javaHome; + } + if (javaHome === '~') { + return homeDir!; + } + if (javaHome.slice(0, 2) !== '~/') { + return javaHome; + } + return path.join(homeDir!, javaHome.slice(2)); + } +} diff --git a/x-pack/plugins/code/server/lsp/language_server_launcher.test.ts b/x-pack/plugins/code/server/lsp/language_server_launcher.test.ts new file mode 100644 index 000000000000000..51468a33e370459 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/language_server_launcher.test.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 fs from 'fs'; +import { ServerOptions } from '../server_options'; +import { createTestServerOption } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { TYPESCRIPT } from './language_servers'; +import { TypescriptServerLauncher } from './ts_launcher'; + +jest.setTimeout(10000); + +// @ts-ignore +const options: ServerOptions = createTestServerOption(); + +beforeAll(async () => { + if (!fs.existsSync(options.workspacePath)) { + fs.mkdirSync(options.workspacePath, { recursive: true }); + fs.mkdirSync(options.jdtWorkspacePath, { recursive: true }); + } +}); + +function delay(seconds: number) { + return new Promise(resolve => { + setTimeout(() => resolve(), seconds * 1000); + }); +} + +test('typescript language server could be shutdown', async () => { + const tsLauncher = new TypescriptServerLauncher('localhost', options, new ConsoleLoggerFactory()); + const proxy = await tsLauncher.launch(true, 1, TYPESCRIPT.embedPath!); + await proxy.initialize(options.workspacePath); + await delay(2); + expect(tsLauncher.running).toBeTruthy(); + await proxy.exit(); + await delay(2); + expect(tsLauncher.running).toBeFalsy(); +}); diff --git a/x-pack/plugins/code/server/lsp/language_server_launcher.ts b/x-pack/plugins/code/server/lsp/language_server_launcher.ts new file mode 100644 index 000000000000000..8b33ac54cdb17d6 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/language_server_launcher.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 { ServerOptions } from '../server_options'; +import { LoggerFactory } from '../utils/log_factory'; +import { ILanguageServerHandler } from './proxy'; + +export interface ILanguageServerLauncher { + running: boolean; + launch( + builtinWorkspace: boolean, + maxWorkspace: number, + installationPath?: string + ): Promise; +} + +export type LauncherConstructor = new ( + targetHost: string, + options: ServerOptions, + loggerFactory: LoggerFactory +) => ILanguageServerLauncher; diff --git a/x-pack/plugins/code/server/lsp/language_servers.ts b/x-pack/plugins/code/server/lsp/language_servers.ts new file mode 100644 index 000000000000000..60e1f328a94baa7 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/language_servers.ts @@ -0,0 +1,51 @@ +/* + * 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 '../../common/installation'; +import { LanguageServer } from '../../common/language_server'; +import { GoLauncher } from './go_launcher'; +import { JavaLauncher } from './java_launcher'; +import { LauncherConstructor } from './language_server_launcher'; +import { TypescriptServerLauncher } from './ts_launcher'; + +export interface LanguageServerDefinition extends LanguageServer { + builtinWorkspaceFolders: boolean; + launcher: LauncherConstructor; + installationFolderName?: string; + downloadUrl?: (lang: LanguageServerDefinition, version: string) => string | string; + embedPath?: string; + installationPluginName?: string; +} + +export const TYPESCRIPT: LanguageServerDefinition = { + name: 'Typescript', + builtinWorkspaceFolders: false, + languages: ['typescript', 'javascript', 'html'], + launcher: TypescriptServerLauncher, + installationType: InstallationType.Embed, + embedPath: require.resolve('@elastic/javascript-typescript-langserver/lib/language-server.js'), +}; +export const JAVA: LanguageServerDefinition = { + name: 'Java', + builtinWorkspaceFolders: true, + languages: ['java'], + launcher: JavaLauncher, + installationType: InstallationType.Plugin, + installationPluginName: 'java-langserver', + installationFolderName: 'jdt', + downloadUrl: (lang: LanguageServerDefinition, version: string) => + `https://download.elasticsearch.org/code/java-langserver/release/java-langserver-${version}-$OS.zip`, +}; +export const GO: LanguageServerDefinition = { + name: 'Go', + builtinWorkspaceFolders: true, + languages: ['go'], + launcher: GoLauncher, + installationType: InstallationType.Plugin, + installationPluginName: 'goLanguageServer', +}; +export const LanguageServers: LanguageServerDefinition[] = [TYPESCRIPT, JAVA]; +export const LanguageServersDeveloping: LanguageServerDefinition[] = [GO]; diff --git a/x-pack/plugins/code/server/lsp/lsp_benchmark.ts b/x-pack/plugins/code/server/lsp/lsp_benchmark.ts new file mode 100644 index 000000000000000..8fbf3aab747988f --- /dev/null +++ b/x-pack/plugins/code/server/lsp/lsp_benchmark.ts @@ -0,0 +1,81 @@ +/* + * 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 yaml from 'js-yaml'; +import { TestConfig, RequestType } from '../../model/test_config'; +import { TestRepoManager } from './test_repo_manager'; +import { LspTestRunner } from './lsp_test_runner'; + +jest.setTimeout(300000); + +let repoManger: TestRepoManager; +const resultFile = `benchmark_result_${Date.now()}.csv`; + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +beforeAll(async () => { + const config: TestConfig = yaml.safeLoad(fs.readFileSync('test_config.yml', 'utf8')); + repoManger = new TestRepoManager(config); + await repoManger.importAllRepos(); +}); + +it('test Java lsp full', async () => { + const repo = repoManger.getRepo('java'); + const runner = new LspTestRunner(repo, RequestType.FULL, 10); + await runner.launchLspByLanguage(); + // sleep until jdt connection established + await sleep(3000); + await runner.sendRandomRequest(); + await runner.proxy!.exit(); + runner.dumpToCSV(resultFile); + expect(true); +}); + +it('test Java lsp hover', async () => { + const repo = repoManger.getRepo('java'); + const runner = new LspTestRunner(repo, RequestType.HOVER, 10); + await runner.launchLspByLanguage(); + // sleep until jdt connection established + await sleep(3000); + await runner.sendRandomRequest(); + await runner.proxy!.exit(); + runner.dumpToCSV(resultFile); + expect(true); +}); + +it('test ts lsp full', async () => { + const repo = repoManger.getRepo('ts'); + const runner = new LspTestRunner(repo, RequestType.FULL, 10); + await runner.launchLspByLanguage(); + await sleep(2000); + await runner.sendRandomRequest(); + await runner.proxy!.exit(); + runner.dumpToCSV(resultFile); + await sleep(2000); + expect(true); +}); + +it('test ts lsp hover', async () => { + const repo = repoManger.getRepo('ts'); + const runner = new LspTestRunner(repo, RequestType.HOVER, 10); + await runner.launchLspByLanguage(); + await sleep(3000); + await runner.sendRandomRequest(); + await runner.proxy!.exit(); + runner.dumpToCSV(resultFile); + await sleep(2000); + expect(true); +}); + +afterAll(async () => { + // eslint-disable-next-line no-console + console.log(`result file ${path.resolve(__dirname)}/${resultFile} was saved!`); + await repoManger.cleanAllRepos(); +}); diff --git a/x-pack/plugins/code/server/lsp/lsp_service.ts b/x-pack/plugins/code/server/lsp/lsp_service.ts new file mode 100644 index 000000000000000..1e9459a3dc8bb11 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/lsp_service.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 { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; + +import { LanguageServerStatus } from '../../common/language_server'; +import { EsClient } from '../lib/esqueue'; +import { RepositoryConfigController } from '../repository_config_controller'; +import { ServerOptions } from '../server_options'; +import { LoggerFactory } from '../utils/log_factory'; +import { LanguageServerController } from './controller'; +import { InstallManager } from './install_manager'; +import { WorkspaceHandler } from './workspace_handler'; + +export class LspService { + public readonly controller: LanguageServerController; + public readonly workspaceHandler: WorkspaceHandler; + constructor( + targetHost: string, + serverOptions: ServerOptions, + client: EsClient, + installManager: InstallManager, + loggerFactory: LoggerFactory, + repoConfigController: RepositoryConfigController + ) { + this.workspaceHandler = new WorkspaceHandler( + serverOptions.repoPath, + serverOptions.workspacePath, + client, + loggerFactory + ); + this.controller = new LanguageServerController( + serverOptions, + targetHost, + installManager, + loggerFactory, + repoConfigController + ); + } + + /** + * send a lsp request to language server, will initiate the language server if needed + * @param method the method name + * @param params the request params + * @param timeoutForInitializeMs When this request triggered an initializing, for how many milliseconds the response will wait for it. + */ + public async sendRequest( + method: string, + params: any, + timeoutForInitializeMs?: number + ): Promise { + const request = { method, params, timeoutForInitializeMs }; + await this.workspaceHandler.handleRequest(request); + const response = await this.controller.handleRequest(request); + return this.workspaceHandler.handleResponse(request, response); + } + + public async launchServers() { + await this.controller.launchServers(); + } + + public async deleteWorkspace(repoUri: string) { + for (const path of await this.workspaceHandler.listWorkspaceFolders(repoUri)) { + await this.controller.unloadWorkspace(path); + } + await this.workspaceHandler.clearWorkspace(repoUri); + } + + /** + * shutdown all launched language servers + */ + public async shutdown() { + await this.controller.exit(); + } + + public supportLanguage(lang: string) { + return this.controller.supportLanguage(lang); + } + + public languageServerStatus(lang: string): LanguageServerStatus { + return this.controller.status(lang); + } +} diff --git a/x-pack/plugins/code/server/lsp/lsp_test_runner.ts b/x-pack/plugins/code/server/lsp/lsp_test_runner.ts new file mode 100644 index 000000000000000..5190d485bd8811e --- /dev/null +++ b/x-pack/plugins/code/server/lsp/lsp_test_runner.ts @@ -0,0 +1,252 @@ +/* + * 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 no-console */ + +import fs from 'fs'; +// @ts-ignore +import * as sl from 'stats-lite'; +import _ from 'lodash'; +import papa from 'papaparse'; + +import { InstallManager } from './install_manager'; +import { JavaLauncher } from './java_launcher'; +import { JAVA, TYPESCRIPT } from './language_servers'; +import { RequestExpander } from './request_expander'; +import { TypescriptServerLauncher } from './ts_launcher'; +import { GitOperations } from '../git_operations'; +import { createTestServerOption } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { RepositoryUtils } from '../../common/repository_utils'; +import { LspRequest } from '../../model'; +import { Repo, RequestType } from '../../model/test_config'; + +const requestTypeMapping = new Map([ + [RequestType.FULL, 'full'], + [RequestType.HOVER, 'hover'], + [RequestType.INITIALIZE, 'initialize'], +]); + +interface Result { + repoName: string; + startTime: number; + numberOfRequests: number; + rps: number; + OK: number; + KO: number; + KORate: string; + latency_max: number; + latency_min: number; + latency_medium: number; + latency_95: number; + latency_99: number; + latency_avg: number; + latency_std_dev: number; +} + +const serverOptions = createTestServerOption(); + +export class LspTestRunner { + private repo: Repo; + public result: Result; + public proxy: RequestExpander | null; + private requestType: RequestType; + private times: number; + + constructor(repo: Repo, requestType: RequestType, times: number) { + this.repo = repo; + this.requestType = requestType; + this.times = times; + this.proxy = null; + this.result = { + repoName: `${repo.url}`, + startTime: 0, + numberOfRequests: times, + rps: 0, + OK: 0, + KO: 0, + KORate: '', + latency_max: 0, + latency_min: 0, + latency_medium: 0, + latency_95: 0, + latency_99: 0, + latency_avg: 0, + latency_std_dev: 0, + }; + if (!fs.existsSync(serverOptions.workspacePath)) { + fs.mkdirSync(serverOptions.workspacePath); + } + } + + public async sendRandomRequest() { + const repoPath: string = this.repo.path; + const files = await this.getAllFile(); + const randomFile = files[Math.floor(Math.random() * files.length)]; + await this.proxy!.initialize(repoPath); + switch (this.requestType) { + case RequestType.HOVER: { + const req: LspRequest = { + method: 'textDocument/hover', + params: { + textDocument: { + uri: `file://${this.repo.path}/${randomFile}`, + }, + position: this.randomizePosition(), + }, + }; + await this.launchRequest(req); + break; + } + case RequestType.INITIALIZE: { + this.proxy!.initialize(repoPath); + break; + } + case RequestType.FULL: { + const req: LspRequest = { + method: 'textDocument/full', + params: { + textDocument: { + uri: `file://${this.repo.path}/${randomFile}`, + }, + reference: false, + }, + }; + await this.launchRequest(req); + break; + } + default: { + console.error('Unknown request type!'); + break; + } + } + } + + private async launchRequest(req: LspRequest) { + this.result.startTime = Date.now(); + let OK: number = 0; + let KO: number = 0; + const responseTimes = []; + for (let i = 0; i < this.times; i++) { + try { + const start = Date.now(); + await this.proxy!.handleRequest(req); + responseTimes.push(Date.now() - start); + OK += 1; + } catch (e) { + KO += 1; + } + } + this.result.KO = KO; + this.result.OK = OK; + this.result.KORate = ((KO / this.times) * 100).toFixed(2) + '%'; + this.result.rps = this.times / (Date.now() - this.result.startTime); + this.collectMetrics(responseTimes); + } + + private collectMetrics(responseTimes: number[]) { + this.result.latency_max = Math.max.apply(null, responseTimes); + this.result.latency_min = Math.min.apply(null, responseTimes); + this.result.latency_avg = sl.mean(responseTimes); + this.result.latency_medium = sl.median(responseTimes); + this.result.latency_95 = sl.percentile(responseTimes, 0.95); + this.result.latency_99 = sl.percentile(responseTimes, 0.99); + this.result.latency_std_dev = sl.stdev(responseTimes).toFixed(2); + } + + public dumpToCSV(resultFile: string) { + const newResult = _.mapKeys(this.result as _.Dictionary, (v, k) => { + if (k !== 'repoName') { + return `${requestTypeMapping.get(this.requestType)}_${k}`; + } else { + return 'repoName'; + } + }); + if (!fs.existsSync(resultFile)) { + console.log(papa.unparse([newResult])); + fs.writeFileSync(resultFile, papa.unparse([newResult])); + } else { + const file = fs.createReadStream(resultFile); + papa.parse(file, { + header: true, + complete: parsedResult => { + const originResults = parsedResult.data; + const index = originResults.findIndex(originResult => { + return originResult.repoName === newResult.repoName; + }); + if (index === -1) { + originResults.push(newResult); + } else { + originResults[index] = { ...originResults[index], ...newResult }; + } + fs.writeFileSync(resultFile, papa.unparse(originResults)); + }, + }); + } + } + + private async getAllFile() { + const gitOperator: GitOperations = new GitOperations(this.repo.path); + try { + const fileTree = await gitOperator.fileTree( + '', + '', + 'HEAD', + 0, + Number.MAX_SAFE_INTEGER, + false, + Number.MAX_SAFE_INTEGER + ); + return RepositoryUtils.getAllFiles(fileTree).filter((filePath: string) => { + return filePath.endsWith(this.repo.language); + }); + } catch (e) { + console.error(`get files error: ${e}`); + throw e; + } + } + + private randomizePosition() { + // TODO:pcxu randomize position according to source file + return { + line: 19, + character: 2, + }; + } + + public async launchLspByLanguage() { + switch (this.repo.language) { + case 'java': { + this.proxy = await this.launchJavaLanguageServer(); + break; + } + case 'ts': { + this.proxy = await this.launchTypescriptLanguageServer(); + break; + } + default: { + console.error('unknown language type'); + break; + } + } + } + + private async launchTypescriptLanguageServer() { + const launcher = new TypescriptServerLauncher( + '127.0.0.1', + serverOptions, + new ConsoleLoggerFactory() + ); + return await launcher.launch(false, 1, TYPESCRIPT.embedPath!); + } + + private async launchJavaLanguageServer() { + const launcher = new JavaLauncher('127.0.0.1', serverOptions, new ConsoleLoggerFactory()); + // @ts-ignore + const installManager = new InstallManager(null, serverOptions); + return await launcher.launch(false, 1, installManager.installationPath(JAVA)); + } +} diff --git a/x-pack/plugins/code/server/lsp/proxy.ts b/x-pack/plugins/code/server/lsp/proxy.ts new file mode 100644 index 000000000000000..d3e94f1e0e7e415 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/proxy.ts @@ -0,0 +1,308 @@ +/* + * 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 EventEmitter from 'events'; +import * as net from 'net'; +import { + createMessageConnection, + MessageConnection, + SocketMessageReader, + SocketMessageWriter, +} from 'vscode-jsonrpc'; + +import { RequestMessage, ResponseMessage } from 'vscode-jsonrpc/lib/messages'; + +import { + ClientCapabilities, + ExitNotification, + InitializedNotification, + InitializeResult, + LogMessageNotification, + MessageType, + WorkspaceFolder, +} from 'vscode-languageserver-protocol/lib/main'; +import { createConnection, IConnection } from 'vscode-languageserver/lib/main'; + +import { LspRequest } from '../../model'; +import { Logger } from '../log'; +import { LspOptions } from '../server_options'; +import { HttpMessageReader } from './http_message_reader'; +import { HttpMessageWriter } from './http_message_writer'; +import { HttpRequestEmitter } from './http_request_emitter'; +import { createRepliesMap } from './replies_map'; + +export interface ILanguageServerHandler { + lastAccess?: number; + handleRequest(request: LspRequest): Promise; + exit(): Promise; + unloadWorkspace(workspaceDir: string): Promise; +} + +export class LanguageServerProxy implements ILanguageServerHandler { + public get isClosed() { + return this.closed; + } + + public initialized: boolean = false; + private socket: any; + private conn: IConnection; + private clientConnection: MessageConnection | null = null; + private closed: boolean = false; + private sequenceNumber = 0; + private httpEmitter = new HttpRequestEmitter(); + private replies = createRepliesMap(); + private readonly targetHost: string; + private readonly targetPort: number; + private readonly logger: Logger; + private readonly lspOptions: LspOptions; + private eventEmitter = new EventEmitter(); + + private connectingPromise?: Promise; + + constructor(targetPort: number, targetHost: string, logger: Logger, lspOptions: LspOptions) { + this.targetHost = targetHost; + this.targetPort = targetPort; + this.logger = logger; + this.lspOptions = lspOptions; + this.conn = createConnection( + new HttpMessageReader(this.httpEmitter), + new HttpMessageWriter(this.replies, logger) + ); + } + public handleRequest(request: LspRequest): Promise { + return this.receiveRequest(request.method, request.params, request.isNotification); + } + + public receiveRequest(method: string, params: any, isNotification: boolean = false) { + const message: RequestMessage = { + jsonrpc: '2.0', + id: this.sequenceNumber++, + method, + params, + }; + return new Promise((resolve, reject) => { + if (this.lspOptions.verbose) { + this.logger.info(`emit message ${JSON.stringify(message)}`); + } else { + this.logger.debug(`emit message ${JSON.stringify(message)}`); + } + if (isNotification) { + // for language server as jdt, notification won't have a response message. + this.httpEmitter.emit('message', message); + resolve(); + } else { + this.replies.set(message.id as number, [resolve, reject]); + this.httpEmitter.emit('message', message); + } + }); + } + public async initialize( + clientCapabilities: ClientCapabilities, + workspaceFolders: [WorkspaceFolder], + initOptions?: object + ): Promise { + const clientConn = await this.connect(); + const rootUri = workspaceFolders[0].uri; + const params = { + processId: null, + workspaceFolders, + rootUri, + capabilities: clientCapabilities, + }; + return await clientConn + .sendRequest( + 'initialize', + initOptions ? { ...params, initializationOptions: initOptions } : params + ) + .then(r => { + this.logger.info(`initialized at ${rootUri}`); + + // @ts-ignore + // TODO fix this + clientConn.sendNotification(InitializedNotification.type, {}); + this.initialized = true; + return r as InitializeResult; + }); + } + + public listen() { + this.conn.onRequest((method: string, ...params) => { + if (this.lspOptions.verbose) { + this.logger.info('received request method: ' + method); + } else { + this.logger.debug('received request method: ' + method); + } + + return this.connect().then(clientConn => { + if (this.lspOptions.verbose) { + this.logger.info(`proxy method:${method} to Language Server `); + } else { + this.logger.debug(`proxy method:${method} to Language Server `); + } + + return clientConn.sendRequest(method, ...params); + }); + }); + this.conn.listen(); + } + + public async shutdown() { + const clientConn = await this.connect(); + this.logger.info(`sending shutdown request`); + return await clientConn.sendRequest('shutdown'); + } + /** + * send a exit request to Language Server + * https://microsoft.github.io/language-server-protocol/specification#exit + */ + public async exit() { + if (this.clientConnection) { + this.logger.info('sending `shutdown` request to language server.'); + const clientConn = this.clientConnection; + await clientConn.sendRequest('shutdown').then(() => { + this.logger.info('sending `exit` notification to language server.'); + + // @ts-ignore + // TODO fix this + clientConn.sendNotification(ExitNotification.type); + this.conn.dispose(); // stop listening + }); + } + this.closed = true; // stop the socket reconnect + this.eventEmitter.emit('exit'); + } + + public awaitServerConnection() { + return new Promise((res, rej) => { + const server = net.createServer(socket => { + this.initialized = false; + server.close(); + this.eventEmitter.emit('connect'); + socket.on('close', () => this.onSocketClosed()); + + this.logger.info('Java langserver connection established on port ' + this.targetPort); + + const reader = new SocketMessageReader(socket); + const writer = new SocketMessageWriter(socket); + this.clientConnection = createMessageConnection(reader, writer, this.logger); + this.registerOnNotificationHandler(this.clientConnection); + this.clientConnection.listen(); + res(this.clientConnection); + }); + server.on('error', rej); + server.listen(this.targetPort, () => { + server.removeListener('error', rej); + this.logger.info('Wait Java langserver connection on port ' + this.targetPort); + }); + }); + } + + /** + * get notification when proxy's socket disconnect + * @param listener + */ + public onDisconnected(listener: () => void) { + this.eventEmitter.on('close', listener); + } + + public onExit(listener: () => void) { + this.eventEmitter.on('exit', listener); + } + + /** + * get notification when proxy's socket connect + * @param listener + */ + public onConnected(listener: () => void) { + this.eventEmitter.on('connect', listener); + } + + public connect(): Promise { + if (this.clientConnection) { + return Promise.resolve(this.clientConnection); + } + this.closed = false; + if (!this.connectingPromise) { + this.connectingPromise = new Promise((resolve, reject) => { + this.socket = new net.Socket(); + + this.socket.on('connect', () => { + const reader = new SocketMessageReader(this.socket); + const writer = new SocketMessageWriter(this.socket); + this.clientConnection = createMessageConnection(reader, writer, this.logger); + this.registerOnNotificationHandler(this.clientConnection); + this.clientConnection.listen(); + resolve(this.clientConnection); + this.eventEmitter.emit('connect'); + }); + + this.socket.on('close', () => this.onSocketClosed()); + + this.socket.on('error', () => void 0); + this.socket.on('timeout', () => void 0); + this.socket.on('drain', () => void 0); + this.socket.connect( + this.targetPort, + this.targetHost + ); + this.onDisconnected(() => setTimeout(() => this.reconnect(), 1000)); + }); + } + return this.connectingPromise; + } + + public unloadWorkspace(workspaceDir: string): Promise { + return Promise.reject('should not hit here'); + } + + private reconnect() { + if (!this.isClosed) { + this.socket.connect( + this.targetPort, + this.targetHost + ); + } + } + + private onSocketClosed() { + if (this.clientConnection) { + this.clientConnection.dispose(); + } + this.clientConnection = null; + this.eventEmitter.emit('close'); + } + + private registerOnNotificationHandler(clientConnection: MessageConnection) { + // @ts-ignore + clientConnection.onNotification(LogMessageNotification.type, notification => { + switch (notification.type) { + case MessageType.Log: + this.logger.debug(notification.message); + break; + case MessageType.Info: + if (this.lspOptions.verbose) { + this.logger.info(notification.message); + } else { + this.logger.debug(notification.message); + } + break; + case MessageType.Warning: + if (this.lspOptions.verbose) { + this.logger.warn(notification.message); + } else { + this.logger.log(notification.message); + } + break; + case MessageType.Error: + if (this.lspOptions.verbose) { + this.logger.error(notification.message); + } else { + this.logger.warn(notification.message); + } + break; + } + }); + } +} diff --git a/x-pack/plugins/code/server/lsp/replies_map.ts b/x-pack/plugins/code/server/lsp/replies_map.ts new file mode 100644 index 000000000000000..12ed53b9b573821 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/replies_map.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. + */ + +import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; + +export type RepliesMap = Map< + number, + [(resp: ResponseMessage) => void, (error: ResponseMessage['error']) => void] +>; + +export function createRepliesMap() { + return new Map() as RepliesMap; +} diff --git a/x-pack/plugins/code/server/lsp/request_expander.test.ts b/x-pack/plugins/code/server/lsp/request_expander.test.ts new file mode 100644 index 000000000000000..1365e9be81db05c --- /dev/null +++ b/x-pack/plugins/code/server/lsp/request_expander.test.ts @@ -0,0 +1,195 @@ +/* + * 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 mkdirp from 'mkdirp'; +import rimraf from 'rimraf'; +import sinon from 'sinon'; +import { ServerOptions } from '../server_options'; +import { LanguageServerProxy } from './proxy'; +import { InitializingError, RequestExpander } from './request_expander'; + +// @ts-ignore +const options: ServerOptions = { + workspacePath: '/tmp/test/workspace', +}; + +beforeEach(async () => { + sinon.reset(); + if (!fs.existsSync(options.workspacePath)) { + mkdirp.sync(options.workspacePath); + } +}); + +afterEach(() => { + return new Promise(resolve => { + rimraf(options.workspacePath, resolve); + }); +}); + +test('requests should be sequential', async () => { + // @ts-ignore + const proxyStub = sinon.createStubInstance(LanguageServerProxy, { + handleRequest: sinon.stub().callsFake(() => { + const start = Date.now(); + return new Promise(resolve => { + setTimeout(() => { + resolve({ result: { start, end: Date.now() } }); + }, 100); + }); + }), + }); + const expander = new RequestExpander(proxyStub, false, 1, options); + const request1 = { + method: 'request1', + params: [], + }; + const request2 = { + method: 'request2', + params: [], + }; + const response1Promise = expander.handleRequest(request1); + const response2Promise = expander.handleRequest(request2); + const response1 = await response1Promise; + const response2 = await response2Promise; + // response2 should not be started before response1 ends. + expect(response1.result.end).toBeLessThanOrEqual(response2.result.start); +}); + +test('requests should throw error after lsp init timeout', async () => { + // @ts-ignore + const proxyStub = sinon.createStubInstance(LanguageServerProxy, { + handleRequest: sinon.stub().callsFake(() => Promise.resolve('ok')), + initialize: sinon.stub().callsFake( + () => + new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 200); + }) + ), + }); + const expander = new RequestExpander(proxyStub, false, 1, options); + const request1 = { + method: 'request1', + params: [], + workspacePath: '/tmp/test/workspace/1', + timeoutForInitializeMs: 100, + }; + mkdirp.sync(request1.workspacePath); + const request2 = { + method: 'request1', + params: [], + workspacePath: '/tmp/test/workspace/1', + timeoutForInitializeMs: 100, + }; + mkdirp.sync(request1.workspacePath); + const response1Promise = expander.handleRequest(request1); + const response2Promise = expander.handleRequest(request2); + await expect(response1Promise).rejects.toEqual(InitializingError); + await expect(response2Promise).rejects.toEqual(InitializingError); +}); + +test('be able to open multiple workspace', async () => { + // @ts-ignore + const proxyStub = sinon.createStubInstance(LanguageServerProxy, { + initialize: sinon.stub().callsFake(() => { + proxyStub.initialized = true; + return Promise.resolve(); + }), + handleRequest: sinon.stub().resolvesArg(0), + }); + const expander = new RequestExpander(proxyStub, true, 2, options); + const request1 = { + method: 'request1', + params: [], + workspacePath: '/tmp/test/workspace/1', + }; + const request2 = { + method: 'request2', + params: [], + workspacePath: '/tmp/test/workspace/2', + }; + mkdirp.sync(request1.workspacePath); + mkdirp.sync(request2.workspacePath); + await expander.handleRequest(request1); + await expander.handleRequest(request2); + expect(proxyStub.initialize.called); + expect( + proxyStub.initialize.calledOnceWith({}, [ + { + name: request1.workspacePath, + uri: `file://${request1.workspacePath}`, + }, + ]) + ).toBeTruthy(); + expect( + proxyStub.handleRequest.calledWith({ + method: 'workspace/didChangeWorkspaceFolders', + params: { + event: { + added: [ + { + name: request2.workspacePath, + uri: `file://${request2.workspacePath}`, + }, + ], + removed: [], + }, + }, + isNotification: true, + }) + ).toBeTruthy(); +}); + +test('be able to swap workspace', async () => { + // @ts-ignore + const proxyStub = sinon.createStubInstance(LanguageServerProxy, { + initialize: sinon.stub().callsFake(() => { + proxyStub.initialized = true; + return Promise.resolve(); + }), + handleRequest: sinon.stub().resolvesArg(0), + }); + const expander = new RequestExpander(proxyStub, true, 1, options); + const request1 = { + method: 'request1', + params: [], + workspacePath: '/tmp/test/workspace/1', + }; + const request2 = { + method: 'request2', + params: [], + workspacePath: '/tmp/test/workspace/2', + }; + mkdirp.sync(request1.workspacePath); + mkdirp.sync(request2.workspacePath); + await expander.handleRequest(request1); + await expander.handleRequest(request2); + expect(proxyStub.initialize.called); + expect( + proxyStub.handleRequest.calledWith({ + method: 'workspace/didChangeWorkspaceFolders', + params: { + event: { + added: [ + { + name: request2.workspacePath, + uri: `file://${request2.workspacePath}`, + }, + ], + removed: [ + { + name: request1.workspacePath, + uri: `file://${request1.workspacePath}`, + }, + ], + }, + }, + isNotification: true, + }) + ).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/lsp/request_expander.ts b/x-pack/plugins/code/server/lsp/request_expander.ts new file mode 100644 index 000000000000000..08eeee96c76f333 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/request_expander.ts @@ -0,0 +1,291 @@ +/* + * 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 { ResponseError, ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { DidChangeWorkspaceFoldersParams, InitializeResult } from 'vscode-languageserver-protocol'; +import { ServerNotInitialized } from '../../common/lsp_error_codes'; +import { LspRequest } from '../../model'; +import { ServerOptions } from '../server_options'; +import { promiseTimeout } from '../utils/timeout'; +import { ILanguageServerHandler, LanguageServerProxy } from './proxy'; + +interface Job { + request: LspRequest; + resolve: (response: ResponseMessage) => void; + reject: (error: any) => void; + startTime: number; +} + +enum WorkspaceStatus { + Uninitialized, + Initializing, + Initialized, +} + +interface Workspace { + lastAccess: number; + status: WorkspaceStatus; + initPromise?: Promise; +} + +export const InitializingError = new ResponseError(ServerNotInitialized, 'Server is initializing'); + +export class RequestExpander implements ILanguageServerHandler { + public lastAccess: number = 0; + private proxy: LanguageServerProxy; + private jobQueue: Job[] = []; + // a map for workspacePath -> Workspace + private workspaces: Map = new Map(); + private workspaceRoot: string; + private running = false; + + constructor( + proxy: LanguageServerProxy, + readonly builtinWorkspace: boolean, + readonly maxWorkspace: number, + readonly serverOptions: ServerOptions, + readonly initialOptions?: object + ) { + this.proxy = proxy; + this.handle = this.handle.bind(this); + proxy.onDisconnected(() => { + this.workspaces.clear(); + }); + this.workspaceRoot = fs.realpathSync(this.serverOptions.workspacePath); + } + + public handleRequest(request: LspRequest): Promise { + this.lastAccess = Date.now(); + return new Promise((resolve, reject) => { + this.jobQueue.push({ + request, + resolve, + reject, + startTime: Date.now(), + }); + if (!this.running) { + this.running = true; + this.handleNext(); + } + }); + } + + public async exit() { + return this.proxy.exit(); + } + + public async unloadWorkspace(workspacePath: string) { + if (this.hasWorkspacePath(workspacePath)) { + if (this.builtinWorkspace) { + this.removeWorkspace(workspacePath); + const params: DidChangeWorkspaceFoldersParams = { + event: { + removed: [ + { + name: workspacePath!, + uri: `file://${workspacePath}`, + }, + ], + added: [], + }, + }; + await this.proxy.handleRequest({ + method: 'workspace/didChangeWorkspaceFolders', + params, + isNotification: true, + }); + } else { + await this.exit(); + } + } + } + + public async initialize(workspacePath: string): Promise { + this.updateWorkspace(workspacePath); + const ws = this.getWorkspace(workspacePath); + ws.status = WorkspaceStatus.Initializing; + + try { + if (this.builtinWorkspace) { + if (this.proxy.initialized) { + await this.changeWorkspaceFolders(workspacePath, this.maxWorkspace); + } else { + // this is the first workspace, init the lsp server first + await this.sendInitRequest(workspacePath); + } + ws.status = WorkspaceStatus.Initialized; + } else { + for (const w of this.workspaces.values()) { + if (w.status === WorkspaceStatus.Initialized) { + await this.proxy.shutdown(); + this.workspaces.clear(); + break; + } + } + const response = await this.sendInitRequest(workspacePath); + ws.status = WorkspaceStatus.Initialized; + return response; + } + } catch (e) { + ws.status = WorkspaceStatus.Uninitialized; + throw e; + } + } + + private async sendInitRequest(workspacePath: string) { + return await this.proxy.initialize( + {}, + [ + { + name: workspacePath, + uri: `file://${workspacePath}`, + }, + ], + this.initialOptions + ); + } + + private handle() { + const job = this.jobQueue.shift(); + if (job) { + const { request, resolve, reject } = job; + this.expand(request, job.startTime).then( + value => { + try { + resolve(value); + } finally { + this.handleNext(); + } + }, + err => { + try { + reject(err); + } finally { + this.handleNext(); + } + } + ); + } else { + this.running = false; + } + } + + private handleNext() { + setTimeout(this.handle, 0); + } + + private async expand(request: LspRequest, startTime: number): Promise { + if (request.workspacePath) { + const ws = this.getWorkspace(request.workspacePath); + if (ws.status === WorkspaceStatus.Uninitialized) { + ws.initPromise = this.initialize(request.workspacePath); + } + // Uninitialized or initializing + if (ws.status !== WorkspaceStatus.Initialized) { + const timeout = request.timeoutForInitializeMs || 0; + + if (timeout > 0 && ws.initPromise) { + try { + const elasped = Date.now() - startTime; + await promiseTimeout(timeout - elasped, ws.initPromise); + } catch (e) { + if (e.isTimeout) { + throw InitializingError; + } + throw e; + } + } else if (ws.initPromise) { + await ws.initPromise; + } else { + throw InitializingError; + } + } + } + return await this.proxy.handleRequest(request); + } + + /** + * use DidChangeWorkspaceFolders notification add a new workspace folder + * replace the oldest one if count > maxWorkspace + * builtinWorkspace = false is equal to maxWorkspace =1 + * @param workspacePath + * @param maxWorkspace + */ + private async changeWorkspaceFolders(workspacePath: string, maxWorkspace: number): Promise { + const params: DidChangeWorkspaceFoldersParams = { + event: { + added: [ + { + name: workspacePath!, + uri: `file://${workspacePath}`, + }, + ], + removed: [], + }, + }; + this.updateWorkspace(workspacePath); + + if (this.workspaces.size > this.maxWorkspace) { + let oldestWorkspace; + let oldestAccess = Number.MAX_VALUE; + for (const [workspace, ws] of this.workspaces) { + if (ws.lastAccess < oldestAccess) { + oldestAccess = ws.lastAccess; + oldestWorkspace = path.join(this.serverOptions.workspacePath, workspace); + } + } + if (oldestWorkspace) { + params.event.removed.push({ + name: oldestWorkspace, + uri: `file://${oldestWorkspace}`, + }); + this.removeWorkspace(oldestWorkspace); + } + } + // adding a workspace folder may also need initialize + await this.proxy.handleRequest({ + method: 'workspace/didChangeWorkspaceFolders', + params, + isNotification: true, + }); + } + + private removeWorkspace(workspacePath: string) { + this.workspaces.delete(this.relativePath(workspacePath)); + } + + private updateWorkspace(workspacePath: string) { + this.getWorkspace(workspacePath).status = Date.now(); + } + + private hasWorkspacePath(workspacePath: string) { + return this.workspaces.has(this.relativePath(workspacePath)); + } + + /** + * use a relative path to prevent bugs due to symbolic path + * @param workspacePath + */ + private relativePath(workspacePath: string) { + const realPath = fs.realpathSync(workspacePath); + return path.relative(this.workspaceRoot, realPath); + } + + private getWorkspace(workspacePath: string): Workspace { + const p = this.relativePath(workspacePath); + let ws = this.workspaces.get(p); + if (!ws) { + ws = { + status: WorkspaceStatus.Uninitialized, + lastAccess: Date.now(), + }; + this.workspaces.set(p, ws); + } + return ws; + } +} diff --git a/x-pack/plugins/code/server/lsp/test_config.yml b/x-pack/plugins/code/server/lsp/test_config.yml new file mode 100644 index 000000000000000..f359261f802479b --- /dev/null +++ b/x-pack/plugins/code/server/lsp/test_config.yml @@ -0,0 +1,7 @@ +repos: + - url: 'https://github.com/javaee-samples/javaee7-simple-sample.git' + path: '/tmp/test/code/repos/github.com/javaee-samples/javaee7-simple-sample' + language: 'java' + - url: 'https://github.com/Microsoft/TypeScript-Node-Starter.git' + path: '/tmp/test/code/repos/github.com/Microsoft/TypeScript-Node-Starter' + language: 'ts' diff --git a/x-pack/plugins/code/server/lsp/test_repo_manager.ts b/x-pack/plugins/code/server/lsp/test_repo_manager.ts new file mode 100644 index 000000000000000..6e80ad1fdbed92c --- /dev/null +++ b/x-pack/plugins/code/server/lsp/test_repo_manager.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. + */ +/* eslint-disable no-console */ + +import fs from 'fs'; +import Git from '@elastic/nodegit'; +import rimraf from 'rimraf'; + +import { TestConfig, Repo } from '../../model/test_config'; + +export class TestRepoManager { + private repos: Repo[]; + + constructor(testConfig: TestConfig) { + this.repos = testConfig.repos; + } + + public async importAllRepos() { + for (const repo of this.repos) { + await this.importRepo(repo.url, repo.path); + } + } + + public importRepo(url: string, path: string) { + return new Promise(resolve => { + if (!fs.existsSync(path)) { + rimraf(path, error => { + console.log(`begin to import ${url} to ${path}`); + Git.Clone.clone(url, path).then(repo => { + console.log(`import ${url} done`); + resolve(repo); + }); + }); + } else { + resolve(); + } + }); + } + + public async cleanAllRepos() { + this.repos.forEach(repo => { + this.cleanRepo(repo.path); + }); + } + + public async cleanRepo(path: string) { + return new Promise(resolve => { + if (fs.existsSync(path)) { + rimraf(path, resolve); + } else { + resolve(true); + } + }); + } + + public getRepo(language: string): Repo { + return this.repos.filter(repo => { + return repo.language === language; + })[0]; + } +} diff --git a/x-pack/plugins/code/server/lsp/ts_launcher.ts b/x-pack/plugins/code/server/lsp/ts_launcher.ts new file mode 100644 index 000000000000000..e8195ce856694a7 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/ts_launcher.ts @@ -0,0 +1,112 @@ +/* + * 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 { spawn } from 'child_process'; +import getPort from 'get-port'; +import { resolve } from 'path'; +import { Logger } from '../log'; +import { ServerOptions } from '../server_options'; +import { LoggerFactory } from '../utils/log_factory'; +import { ILanguageServerLauncher } from './language_server_launcher'; +import { LanguageServerProxy } from './proxy'; +import { RequestExpander } from './request_expander'; + +export class TypescriptServerLauncher implements ILanguageServerLauncher { + private isRunning: boolean = false; + constructor( + readonly targetHost: string, + readonly options: ServerOptions, + readonly loggerFactory: LoggerFactory + ) {} + + public get running(): boolean { + return this.isRunning; + } + + public async launch(builtinWorkspace: boolean, maxWorkspace: number, installationPath: string) { + let port = 2089; + + if (!this.options.lsp.detach) { + port = await getPort(); + } + const log: Logger = this.loggerFactory.getLogger(['code', `ts@${this.targetHost}:${port}`]); + const proxy = new LanguageServerProxy(port, this.targetHost, log, this.options.lsp); + + if (this.options.lsp.detach) { + log.info('Detach mode, expected langserver launch externally'); + proxy.onConnected(() => { + this.isRunning = true; + }); + proxy.onDisconnected(() => { + this.isRunning = false; + if (!proxy.isClosed) { + log.warn('language server disconnected, reconnecting'); + setTimeout(() => proxy.connect(), 1000); + } + }); + } else { + const spawnTs = () => { + const p = spawn( + 'node', + ['--max_old_space_size=4096', installationPath, '-p', port.toString(), '-c', '1'], + { + detached: false, + stdio: 'pipe', + cwd: resolve(installationPath, '../..'), + } + ); + p.stdout.on('data', data => { + log.stdout(data.toString()); + }); + p.stderr.on('data', data => { + log.stderr(data.toString()); + }); + this.isRunning = true; + p.on('exit', () => (this.isRunning = false)); + return p; + }; + let child = spawnTs(); + log.info(`Launch Typescript Language Server at port ${port}, pid:${child.pid}`); + // TODO: how to properly implement timeout socket connection? maybe config during socket connection + // const reconnect = () => { + // log.debug('reconnecting'); + // promiseTimeout(3000, proxy.connect()).then( + // () => { + // log.info('connected'); + // }, + // () => { + // log.error('unable to connect within 3s, respawn ts server.'); + // child.kill(); + // child = spawnTs(); + // setTimeout(reconnect, 1000); + // } + // ); + // }; + proxy.onDisconnected(() => { + if (!proxy.isClosed) { + log.info('waiting language server to be connected'); + if (!this.isRunning) { + log.error('detect language server killed, respawn ts server.'); + child = spawnTs(); + } + } else { + child.kill(); + } + }); + proxy.onExit(() => { + if (child) { + child.kill(); + } + }); + } + proxy.listen(); + await proxy.connect(); + return new RequestExpander(proxy, builtinWorkspace, maxWorkspace, this.options, { + installNodeDependency: this.options.security.installNodeDependency, + gitHostWhitelist: this.options.security.gitHostWhitelist, + }); + } +} diff --git a/x-pack/plugins/code/server/lsp/workspace_command.ts b/x-pack/plugins/code/server/lsp/workspace_command.ts new file mode 100644 index 000000000000000..121a47b2dd985c6 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/workspace_command.ts @@ -0,0 +1,106 @@ +/* + * 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 { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import LockFile from 'proper-lockfile'; +import stream from 'stream'; +import { RepoCmd, RepoConfig } from '../../model/workspace'; +import { Logger } from '../log'; + +export class WorkspaceCommand { + constructor( + readonly repoConfig: RepoConfig, + readonly workspaceDir: string, + readonly revision: string, + readonly log: Logger + ) {} + + public async runInit(force: boolean) { + if (this.repoConfig.init) { + const versionFile = path.join(this.workspaceDir, 'init.version'); + if (this.checkRevision(versionFile) && !force) { + this.log.info('a same revision exists, init cmd skipped.'); + return; + } + const lockFile = this.workspaceDir; // path.join(this.workspaceDir, 'init.lock'); + + const isLocked = await LockFile.check(lockFile); + if (isLocked) { + this.log.info('another process is running, please try again later'); + return; + } + const release = await LockFile.lock(lockFile); + + try { + const process = this.spawnProcess(this.repoConfig.init); + const logFile = path.join(this.workspaceDir, 'init.log'); + const logFileStream = fs.createWriteStream(logFile, { encoding: 'utf-8', flags: 'a+' }); + this.redirectOutput(process.stdout, logFileStream); + this.redirectOutput(process.stderr, logFileStream, true); + process.on('close', async (code, signal) => { + logFileStream.close(); + await this.writeRevisionFile(versionFile); + this.log.info(`init process finished with code: ${code} signal: ${signal}`); + await release(); + }); + } catch (e) { + this.log.error(e); + release(); + } + } + } + + private spawnProcess(repoCmd: RepoCmd) { + let cmd: string; + let args: string[]; + if (typeof repoCmd === 'string') { + cmd = repoCmd; + args = []; + } else { + [cmd, ...args] = repoCmd as string[]; + } + return spawn(cmd, args, { + detached: false, + cwd: this.workspaceDir, + }); + } + + private redirectOutput(from: stream.Readable, to: fs.WriteStream, isError: boolean = false) { + from.on('data', (data: Buffer) => { + if (isError) { + this.log.error(data.toString('utf-8')); + } else { + this.log.info(data.toString('utf-8')); + } + to.write(data); + }); + } + + /** + * check the revision file in workspace, return true if it exists and its content equals current revision. + * @param versionFile + */ + private checkRevision(versionFile: string) { + if (fs.existsSync(versionFile)) { + const revision = fs.readFileSync(versionFile, 'utf8').trim(); + return revision === this.revision; + } + return false; + } + + private writeRevisionFile(versionFile: string) { + return new Promise((resolve, reject) => { + fs.writeFile(versionFile, this.revision, err => { + if (err) { + reject(err); + } + resolve(); + }); + }); + } +} diff --git a/x-pack/plugins/code/server/lsp/workspace_handler.test.ts b/x-pack/plugins/code/server/lsp/workspace_handler.test.ts new file mode 100644 index 000000000000000..fa560e14e0d2abf --- /dev/null +++ b/x-pack/plugins/code/server/lsp/workspace_handler.test.ts @@ -0,0 +1,122 @@ +/* + * 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 mkdirp from 'mkdirp'; +import * as os from 'os'; +import rimraf from 'rimraf'; +import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { LspRequest } from '../../model'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { WorkspaceHandler } from './workspace_handler'; + +const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code_test')); +const workspaceDir = path.join(baseDir, 'workspace'); +const repoDir = path.join(baseDir, 'repo'); + +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 }; +} + +test('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); + expect(converted).toBe(`git://${repo}/blob/${revision}/${file}`); +}); + +test('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); + expect(converted).toBe(`git://${repo}/blob/${revision}/${file}`); +}); + +test('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); + expect(converted).toBe(`git://${repo}/blob/${revision}/${file}`); +}); + +test('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); + expect(() => handleResponseUri(workspaceHandler, uri)).toThrow(); +}); + +beforeAll(() => { + mkdirp.sync(workspaceDir); + mkdirp.sync(repoDir); +}); + +afterAll(() => { + rimraf.sync(baseDir); +}); diff --git a/x-pack/plugins/code/server/lsp/workspace_handler.ts b/x-pack/plugins/code/server/lsp/workspace_handler.ts new file mode 100644 index 000000000000000..cadcf1e5d5bf0e4 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/workspace_handler.ts @@ -0,0 +1,422 @@ +/* + * 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 { Clone, Commit, Error as GitError, Repository, Reset, TreeEntry } from '@elastic/nodegit'; +import Boom from 'boom'; +import del from 'del'; +import fs from 'fs'; +import { delay } from 'lodash'; +import mkdirp from 'mkdirp'; +import path from 'path'; +import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { Hover, Location, TextDocumentPositionParams } from 'vscode-languageserver'; + +import { DetailSymbolInformation, Full } from '@elastic/lsp-extension'; + +import { RepositoryUtils } from '../../common/repository_utils'; +import { parseLspUrl } from '../../common/uri_util'; +import { LspRequest, WorkerReservedProgress } from '../../model'; +import { getDefaultBranch, GitOperations } from '../git_operations'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositoryObjectClient } from '../search'; +import { LoggerFactory } from '../utils/log_factory'; + +export const MAX_RESULT_COUNT = 20; + +export class WorkspaceHandler { + private git: GitOperations; + private revisionMap: { [uri: string]: string } = {}; + private log: Logger; + private readonly objectClient: RepositoryObjectClient | undefined = undefined; + + constructor( + readonly repoPath: string, + private readonly workspacePath: string, + private readonly client: EsClient, + loggerFactory: LoggerFactory + ) { + this.git = new GitOperations(repoPath); + this.log = loggerFactory.getLogger(['LSP', 'workspace']); + if (this.client) { + this.objectClient = new RepositoryObjectClient(this.client); + } + } + + /** + * open workspace for repositoryUri, update it from bare repository if necessary. + * @param repositoryUri the uri of bare repository. + * @param revision + */ + public async openWorkspace(repositoryUri: string, revision: string) { + // Try get repository clone status with 3 retries at maximum. + const tryGetGitStatus = async (retryCount: number) => { + let gitStatus; + try { + gitStatus = await this.objectClient!.getRepositoryGitStatus(repositoryUri); + } catch (error) { + throw Boom.internal(`checkout workspace on an unknown status repository`); + } + + if ( + !RepositoryUtils.hasFullyCloned(gitStatus.cloneProgress) && + gitStatus.progress < WorkerReservedProgress.COMPLETED + ) { + if (retryCount < 3) { + this.log.debug(`Check repository ${repositoryUri} clone status at trial ${retryCount}`); + return delay(tryGetGitStatus, 3000, retryCount + 1); + } else { + throw Boom.internal(`repository has not been fully cloned yet.`); + } + } + }; + if (this.objectClient) { + await tryGetGitStatus(0); + } + + const bareRepo = await this.git.openRepo(repositoryUri); + const targetCommit = await this.git.getCommit(bareRepo, revision); + const defaultBranch = await getDefaultBranch(bareRepo.path()); + if (revision !== defaultBranch) { + await this.checkCommit(bareRepo, targetCommit); + revision = defaultBranch; + } + let workspaceRepo: Repository; + if (await this.workspaceExists(repositoryUri, revision)) { + workspaceRepo = await this.updateWorkspace(repositoryUri, revision, targetCommit); + } else { + workspaceRepo = await this.cloneWorkspace(bareRepo, repositoryUri, revision); + } + + const workspaceHeadCommit = await workspaceRepo.getHeadCommit(); + if (workspaceHeadCommit.sha() !== targetCommit.sha()) { + const commit = await workspaceRepo.getCommit(targetCommit.sha()); + this.log.info(`checkout ${workspaceRepo.workdir()} to commit ${targetCommit.sha()}`); + // @ts-ignore + const result = await Reset.reset(workspaceRepo, commit, Reset.TYPE.HARD, {}); + if (result !== undefined && result !== GitError.CODE.OK) { + throw Boom.internal(`checkout workspace to commit ${targetCommit.sha()} failed.`); + } + } + this.setWorkspaceRevision(workspaceRepo, workspaceHeadCommit); + return { workspaceRepo, workspaceRevision: workspaceHeadCommit.sha().substring(0, 7) }; + } + + public async listWorkspaceFolders(repoUri: string) { + const workspaceDir = await this.workspaceDir(repoUri); + const isDir = (source: string) => fs.lstatSync(source).isDirectory(); + return fs + .readdirSync(workspaceDir) + .map(name => path.join(workspaceDir, name)) + .filter(isDir); + } + + public async clearWorkspace(repoUri: string, revision?: string) { + const workspaceDir = await this.workspaceDir(repoUri); + if (revision) { + await del([await this.revisionDir(repoUri, revision)], { force: true }); + } else { + await del([workspaceDir], { force: true }); + } + } + + public async handleRequest(request: LspRequest): Promise { + const { method, params } = request; + switch (method) { + case 'textDocument/edefinition': + case 'textDocument/definition': + case 'textDocument/hover': + case 'textDocument/references': + case 'textDocument/documentSymbol': + case 'textDocument/full': { + const payload: TextDocumentPositionParams = params; + const { filePath, workspacePath, workspaceRevision } = await this.resolveUri( + params.textDocument.uri + ); + if (filePath) { + request.documentUri = payload.textDocument.uri; + payload.textDocument.uri = request.resolvedFilePath = filePath; + request.workspacePath = workspacePath; + request.workspaceRevision = workspaceRevision; + } + break; + } + default: + // do nothing + } + } + + public handleResponse(request: LspRequest, response: ResponseMessage): ResponseMessage { + const { method } = request; + switch (method) { + case 'textDocument/hover': { + const result = response.result as Hover; + this.handleHoverContents(result); + return response; + } + case 'textDocument/edefinition': { + let result = response.result; + if (result) { + if (!Array.isArray(result)) { + response.result = result = [result]; + } + for (const def of result) { + this.convertLocation(def.location); + } + } + return response; + } + case 'textDocument/definition': { + const result = response.result; + if (result) { + if (Array.isArray(result)) { + (result as Location[]).forEach(location => this.convertLocation(location)); + } else { + this.convertLocation(result); + } + } + return response; + } + case 'textDocument/full': { + // unify the result of full as a array. + const result = Array.isArray(response.result) + ? (response.result as Full[]) + : [response.result as Full]; + for (const full of result) { + if (full.symbols) { + for (const symbol of full.symbols) { + this.convertLocation(symbol.symbolInformation.location); + + if (symbol.contents !== null || symbol.contents !== undefined) { + this.handleHoverContents(symbol); + } + } + } + if (full.references) { + for (const reference of full.references) { + this.convertLocation(reference.location); + if (reference.target.location) { + this.convertLocation(reference.target.location); + } + } + } + } + response.result = result; + return response; + } + case 'textDocument/references': { + if (response.result) { + const locations = (response.result as Location[]).slice(0, MAX_RESULT_COUNT); + for (const location of locations) { + this.convertLocation(location); + } + response.result = locations; + } + return response; + } + case 'textDocument/documentSymbol': { + if (response.result) { + for (const symbol of response.result) { + this.convertLocation(symbol.location); + } + } + return response; + } + default: + return response; + } + } + + private handleHoverContents(result: Hover | DetailSymbolInformation) { + if (!Array.isArray(result.contents)) { + if (typeof result.contents === 'string') { + result.contents = [{ language: '', value: result.contents }]; + } else { + result.contents = [result.contents as { language: string; value: string }]; + } + } else { + result.contents = Array.from(result.contents).map(c => { + if (typeof c === 'string') { + return { language: '', value: c }; + } else { + return c; + } + }); + } + } + + private parseLocation(location: Location) { + const uri = location.uri; + const prefix = path.sep === '\\' ? 'file:///' : 'file://'; + if (uri && uri.startsWith(prefix)) { + const locationPath = fs.realpathSync(decodeURIComponent(uri.substring(prefix.length))); + const workspacePath = fs.realpathSync(decodeURIComponent(this.workspacePath)); + if (locationPath.startsWith(workspacePath)) { + let relativePath = path.relative(workspacePath, locationPath); + if (path.sep === '\\') { + relativePath = relativePath.replace(/\\/gi, '/'); + } + const regex = /^(.*?\/.*?\/.*?)\/(__.*?\/)?([^_]+?)\/(.*)$/; + const m = relativePath.match(regex); + if (m) { + const repoUri = m[1]; + const revision = m[3]; + const gitRevision = this.revisionMap[`${repoUri}/${revision}`] || revision; + const file = m[4]; + return { repoUri, revision: gitRevision, file }; + } + } + // @ts-ignore + throw new Error("path in response doesn't not starts with workspace path"); + } + return null; + } + + private convertLocation(location: Location) { + if (location) { + const parsedLocation = this.parseLocation(location); + if (parsedLocation) { + const { repoUri, revision, file } = parsedLocation; + location.uri = `git://${repoUri}/blob/${revision}/${file}`; + } + return parsedLocation; + } + } + + private fileUrl(str: string) { + let pathName = str.replace(/\\/g, '/'); + // Windows drive letter must be prefixed with a slash + if (pathName[0] !== '/') { + pathName = '/' + pathName; + } + return 'file://' + pathName; + } + + /** + * convert a git uri to absolute file path, checkout code into workspace + * @param uri the uri + */ + private async resolveUri(uri: string) { + if (uri.startsWith('git://')) { + const { repoUri, file, revision } = parseLspUrl(uri)!; + const { workspaceRepo, workspaceRevision } = await this.openWorkspace(repoUri, revision); + if (file) { + const isValidPath = await this.checkFile(workspaceRepo, file); + if (!isValidPath) { + throw new Error('invalid fle path in requests.'); + } + } + return { + workspacePath: workspaceRepo.workdir(), + filePath: this.fileUrl(path.resolve(workspaceRepo.workdir(), file || '/')), + uri, + workspaceRevision, + }; + } else { + return { + workspacePath: undefined, + workspaceRevision: undefined, + filePath: undefined, + uri, + }; + } + } + + private async checkCommit(repository: Repository, commit: Commit) { + // we only support headCommit now. + const headCommit = await repository.getHeadCommit(); + if (headCommit.sha() !== commit.sha()) { + throw Boom.badRequest(`revision must be master.`); + } + } + + private async workspaceExists(repositoryUri: string, revision: string) { + const workspaceDir = await this.revisionDir(repositoryUri, revision); + return fs.existsSync(workspaceDir); + } + + private async revisionDir(repositoryUri: string, revision: string) { + return path.join(await this.workspaceDir(repositoryUri), revision); + } + + private async workspaceDir(repoUri: string) { + const randomStr = + this.objectClient && (await this.objectClient.getRepositoryRandomStr(repoUri)); + const base = path.join(this.workspacePath, repoUri); + if (randomStr) { + return path.join(base, `__${randomStr}`); + } else { + return base; + } + } + + private async updateWorkspace( + repositoryUri: string, + revision: string, + targetCommit: Commit + ): Promise { + const workspaceDir = await this.revisionDir(repositoryUri, revision); + const workspaceRepo = await Repository.open(workspaceDir); + const workspaceHead = await workspaceRepo.getHeadCommit(); + if (workspaceHead.sha() !== targetCommit.sha()) { + this.log.info(`fetch workspace ${workspaceDir} from origin`); + await workspaceRepo.fetch('origin'); + } + return workspaceRepo; + } + + private async cloneWorkspace( + bareRepo: Repository, + repositoryUri: string, + revision: string + ): Promise { + const workspaceDir = await this.revisionDir(repositoryUri, revision); + this.log.info(`clone workspace ${workspaceDir} from url ${bareRepo.path()}`); + const parentDir = path.dirname(workspaceDir); + // on windows, git clone will failed if parent folder is not exists; + await new Promise((resolve, reject) => + mkdirp(parentDir, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }) + ); + return await Clone.clone(bareRepo.path(), workspaceDir); + } + + private setWorkspaceRevision(workspaceRepo: Repository, headCommit: Commit) { + const workspaceRelativePath = path.relative(this.workspacePath, workspaceRepo.workdir()); + this.revisionMap[workspaceRelativePath] = headCommit.sha().substring(0, 7); + } + + /** + * check whether the file path specify in the request is valid. The file path must: + * 1. exists in git repo + * 2. is a valid file or dir, can't be a link or submodule + * + * @param workspaceRepo + * @param filePath + */ + private async checkFile(workspaceRepo: Repository, filePath: string) { + const headCommit = await workspaceRepo.getHeadCommit(); + try { + const entry = await headCommit.getEntry(filePath); + switch (entry.filemode()) { + case TreeEntry.FILEMODE.TREE: + case TreeEntry.FILEMODE.BLOB: + case TreeEntry.FILEMODE.EXECUTABLE: + return true; + default: + return false; + } + } catch (e) { + // filePath may not exists + return false; + } + } +} diff --git a/x-pack/plugins/code/server/poller.ts b/x-pack/plugins/code/server/poller.ts new file mode 100644 index 000000000000000..22599d46016bf48 --- /dev/null +++ b/x-pack/plugins/code/server/poller.ts @@ -0,0 +1,103 @@ +/* + * 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. + */ + +/* + * Borrowed from https://github.com/elastic/kibana/blob/master/x-pack/common/poller.js + */ + +// Because the timers lib is global for Nodejs, it's not necessary to explicit import it. +// Also explicitly importing this lib is going to make Sinon fake timer fail to work. +// import { clearTimeout, setTimeout } from 'timers'; + +const noop = () => { + // noop +}; + +interface PollerOptions { + pollFrequencyInMillis: number; + functionToPoll: Poller['functionToPoll']; + successFunction?: Poller['successFunction']; + errorFunction?: Poller['errorFunction']; + trailing?: boolean; + continuePollingOnError?: boolean; + pollFrequencyErrorMultiplier?: number; +} + +export class Poller { + private readonly pollFrequencyInMillis: number; + private readonly functionToPoll: () => Promise; + private readonly successFunction: (result: T) => Promise | void; + private readonly errorFunction: (error: Error) => Promise | void; + private readonly trailing: boolean; + private readonly continuePollingOnError: boolean; + private readonly pollFrequencyErrorMultiplier: number; + + private timeoutId?: NodeJS.Timer; + + constructor(options: PollerOptions) { + this.functionToPoll = options.functionToPoll; // Must return a Promise + this.successFunction = options.successFunction || noop; + this.errorFunction = options.errorFunction || noop; + this.pollFrequencyInMillis = options.pollFrequencyInMillis; + this.trailing = options.trailing || false; + this.continuePollingOnError = options.continuePollingOnError || false; + this.pollFrequencyErrorMultiplier = options.pollFrequencyErrorMultiplier || 1; + } + + public getPollFrequency() { + return this.pollFrequencyInMillis; + } + + public start() { + if (this.isRunning()) { + return; + } + + if (this.trailing) { + this.timeoutId = global.setTimeout(this.poll.bind(this), this.pollFrequencyInMillis); + } else { + this.poll(); + } + } + + public stop() { + if (this.timeoutId) { + global.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + } + + public isRunning() { + return !!this.timeoutId; + } + + private async poll() { + try { + await this.successFunction(await this.functionToPoll()); + + if (!this.isRunning()) { + return; + } + + this.timeoutId = global.setTimeout(this.poll.bind(this), this.pollFrequencyInMillis); + } catch (error) { + await this.errorFunction(error); + + if (!this.isRunning()) { + return; + } + + if (this.continuePollingOnError) { + this.timeoutId = global.setTimeout( + this.poll.bind(this), + this.pollFrequencyInMillis * this.pollFrequencyErrorMultiplier + ); + } else { + this.stop(); + } + } + } +} diff --git a/x-pack/plugins/code/server/queue/abstract_git_worker.ts b/x-pack/plugins/code/server/queue/abstract_git_worker.ts new file mode 100644 index 000000000000000..d644952dcbf8f73 --- /dev/null +++ b/x-pack/plugins/code/server/queue/abstract_git_worker.ts @@ -0,0 +1,93 @@ +/* + * 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 { RepositoryUtils } from '../../common/repository_utils'; +import { + CloneProgress, + CloneWorkerProgress, + CloneWorkerResult, + WorkerReservedProgress, +} from '../../model'; +import { getDefaultBranch, getHeadRevision } from '../git_operations'; +import { EsClient, Esqueue } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositoryObjectClient } from '../search'; +import { ServerOptions } from '../server_options'; +import { AbstractWorker } from './abstract_worker'; +import { Job } from './job'; + +export abstract class AbstractGitWorker extends AbstractWorker { + public id: string = 'abstract-git'; + protected objectClient: RepositoryObjectClient; + + constructor( + protected readonly queue: Esqueue, + protected readonly log: Logger, + protected readonly client: EsClient, + protected readonly serverOptions: ServerOptions + ) { + super(queue, log); + this.objectClient = new RepositoryObjectClient(client); + } + + public async onJobCompleted(job: Job, res: CloneWorkerResult) { + await super.onJobCompleted(job, res); + + // Update the default branch. + const repoUri = res.uri; + const localPath = RepositoryUtils.repositoryLocalPath(this.serverOptions.repoPath, repoUri); + const revision = await getHeadRevision(localPath); + const defaultBranch = await getDefaultBranch(localPath); + + // Update the repository data. + try { + await this.objectClient.updateRepository(repoUri, { + defaultBranch, + revision, + }); + } catch (error) { + this.log.error(`Update repository default branch and revision error.`); + this.log.error(error); + } + + // Update the git operation status. + try { + return await this.objectClient.updateRepositoryGitStatus(repoUri, { + revision, + progress: WorkerReservedProgress.COMPLETED, + cloneProgress: { + isCloned: true, + }, + }); + } catch (error) { + this.log.error(`Update revision of repo clone done error.`); + this.log.error(error); + } + } + + public async updateProgress( + job: Job, + progress: number, + error?: Error, + cloneProgress?: CloneProgress + ) { + const { uri } = job.payload; + const p: CloneWorkerProgress = { + uri, + progress, + timestamp: new Date(), + cloneProgress, + errorMessage: error ? error.message : undefined, + }; + try { + return await this.objectClient.updateRepositoryGitStatus(uri, p); + } catch (err) { + // This is a warning since it's not blocking anything. + this.log.warn(`Update git clone progress error.`); + this.log.warn(err); + } + } +} diff --git a/x-pack/plugins/code/server/queue/abstract_worker.ts b/x-pack/plugins/code/server/queue/abstract_worker.ts new file mode 100644 index 000000000000000..8bb16b8d36e0ef3 --- /dev/null +++ b/x-pack/plugins/code/server/queue/abstract_worker.ts @@ -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 moment from 'moment'; + +import { WorkerReservedProgress, WorkerResult } from '../../model'; +import { + CancellationToken, + Esqueue, + events as esqueueEvents, + Job as JobInternal, +} from '../lib/esqueue'; +import { Logger } from '../log'; +import { Job } from './job'; +import { Worker } from './worker'; + +export abstract class AbstractWorker implements Worker { + // The id of the worker. Also serves as the id of the job this worker consumes. + protected id = ''; + + constructor(protected readonly queue: Esqueue, protected readonly log: Logger) {} + + // Assemble jobs, for now most of the job object construction should be the same. + public async createJob(payload: any, options: any): Promise { + const timestamp = moment().valueOf(); + if (options.timeout !== undefined && options.timeout !== null) { + // If the job explicitly specify the timeout, then honor it. + return { + payload, + options, + timestamp, + }; + } else { + // Otherwise, use a default timeout. + const timeout = await this.getTimeoutMs(payload); + return { + payload, + options: { + ...options, + timeout, + }, + timestamp, + }; + } + } + + public async executeJob(job: Job): Promise { + // This is an abstract class. Do nothing here. You should override this. + return new Promise((resolve, _) => { + resolve(); + }); + } + + // Enqueue the job. + public async enqueueJob(payload: any, options: any) { + const job: Job = await this.createJob(payload, options); + return new Promise((resolve, reject) => { + const jobInternal: JobInternal = this.queue.addJob(this.id, job, job.options); + jobInternal.on(esqueueEvents.EVENT_JOB_CREATED, async (createdJob: JobInternal) => { + if (createdJob.id === jobInternal.id) { + await this.onJobEnqueued(job); + resolve(jobInternal); + } + }); + jobInternal.on(esqueueEvents.EVENT_JOB_CREATE_ERROR, reject); + }); + } + + public bind() { + const workerFn = (payload: any, cancellationToken: CancellationToken) => { + const job: Job = { + ...payload, + cancellationToken, + }; + return this.executeJob(job); + }; + + const workerOptions = { + interval: 5000, + capacity: 5, + intervalErrorMultiplier: 1, + }; + + const queueWorker = this.queue.registerWorker(this.id, workerFn as any, workerOptions); + + queueWorker.on(esqueueEvents.EVENT_WORKER_COMPLETE, async (res: any) => { + const result: WorkerResult = res.output.content; + const job: Job = res.job; + await this.onJobCompleted(job, result); + }); + queueWorker.on(esqueueEvents.EVENT_WORKER_JOB_EXECUTION_ERROR, async (res: any) => { + await this.onJobExecutionError(res); + }); + queueWorker.on(esqueueEvents.EVENT_WORKER_JOB_TIMEOUT, async (res: any) => { + await this.onJobTimeOut(res); + }); + + return this; + } + + public async onJobEnqueued(job: Job) { + this.log.info(`${this.id} job enqueued with details ${JSON.stringify(job)}`); + return await this.updateProgress(job, WorkerReservedProgress.INIT); + } + + public async onJobCompleted(job: Job, res: any) { + this.log.info( + `${this.id} job completed with result ${JSON.stringify( + res + )} in ${this.workerTaskDurationSeconds(job)} seconds.` + ); + return await this.updateProgress(job, WorkerReservedProgress.COMPLETED); + } + + public async onJobExecutionError(res: any) { + this.log.error( + `${this.id} job execution error ${JSON.stringify(res)} in ${this.workerTaskDurationSeconds( + res.job + )} seconds.` + ); + return await this.updateProgress(res.job, WorkerReservedProgress.ERROR, res.error); + } + + public async onJobTimeOut(res: any) { + this.log.error( + `${this.id} job timed out ${JSON.stringify(res)} in ${this.workerTaskDurationSeconds( + res.job + )} seconds.` + ); + return await this.updateProgress(res.job, WorkerReservedProgress.TIMEOUT, res.error); + } + + public async updateProgress(job: Job, progress: number, error?: Error) { + // This is an abstract class. Do nothing here. You should override this. + return new Promise((resolve, _) => { + resolve(); + }); + } + + protected async getTimeoutMs(payload: any) { + // Set to 1 hour by default. Override this function for sub classes if necessary. + return moment.duration(1, 'hour').asMilliseconds(); + } + + private workerTaskDurationSeconds(job: Job) { + const diff = moment().diff(moment(job.timestamp)); + return moment.duration(diff).asSeconds(); + } +} diff --git a/x-pack/plugins/code/server/queue/cancellation_service.test.ts b/x-pack/plugins/code/server/queue/cancellation_service.test.ts new file mode 100644 index 000000000000000..e5ba56d2264b524 --- /dev/null +++ b/x-pack/plugins/code/server/queue/cancellation_service.test.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 { CancellationToken } from '../lib/esqueue'; + +import sinon from 'sinon'; + +import { CancellationSerivce } from './cancellation_service'; + +afterEach(() => { + sinon.restore(); +}); + +test('Register and cancel cancellation token', () => { + const repoUri = 'github.com/elastic/code'; + const service = new CancellationSerivce(); + const token = { + cancel: (): void => { + return; + }, + }; + const cancelSpy = sinon.spy(); + token.cancel = cancelSpy; + + service.registerIndexJobToken(repoUri, token as CancellationToken); + service.cancelIndexJob(repoUri); + + expect(cancelSpy.calledOnce).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/queue/cancellation_service.ts b/x-pack/plugins/code/server/queue/cancellation_service.ts new file mode 100644 index 000000000000000..4cee75e8cd28d27 --- /dev/null +++ b/x-pack/plugins/code/server/queue/cancellation_service.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 { RepositoryUri } from '../../model'; +import { CancellationToken } from '../lib/esqueue'; + +export class CancellationSerivce { + // TODO: Add clone/update cancellation map. + private indexCancellationMap: Map; + + constructor() { + this.indexCancellationMap = new Map(); + } + + public cancelIndexJob(repoUri: RepositoryUri) { + const token = this.indexCancellationMap.get(repoUri); + if (token) { + token.cancel(); + this.indexCancellationMap.delete(repoUri); + } + } + + public registerIndexJobToken(repoUri: RepositoryUri, cancellationToken: CancellationToken) { + const token = this.indexCancellationMap.get(repoUri); + if (token) { + token.cancel(); + } + this.indexCancellationMap.set(repoUri, cancellationToken); + } +} diff --git a/x-pack/plugins/code/server/queue/clone_worker.ts b/x-pack/plugins/code/server/queue/clone_worker.ts new file mode 100644 index 000000000000000..8781fef2a5915dd --- /dev/null +++ b/x-pack/plugins/code/server/queue/clone_worker.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 { delay } from 'lodash'; + +import { validateGitUrl } from '../../common/git_url_utils'; +import { RepositoryUtils } from '../../common/repository_utils'; +import { + CloneProgress, + CloneWorkerProgress, + CloneWorkerResult, + WorkerReservedProgress, +} from '../../model'; +import { EsClient, Esqueue } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositoryServiceFactory } from '../repository_service_factory'; +import { ServerOptions } from '../server_options'; +import { AbstractGitWorker } from './abstract_git_worker'; +import { IndexWorker } from './index_worker'; +import { Job } from './job'; + +export class CloneWorker extends AbstractGitWorker { + public id: string = 'clone'; + + constructor( + protected readonly queue: Esqueue, + protected readonly log: Logger, + protected readonly client: EsClient, + protected readonly serverOptions: ServerOptions, + private readonly indexWorker: IndexWorker, + private readonly repoServiceFactory: RepositoryServiceFactory + ) { + super(queue, log, client, serverOptions); + } + + public async executeJob(job: Job) { + const { url } = job.payload; + try { + validateGitUrl( + url, + this.serverOptions.security.gitHostWhitelist, + this.serverOptions.security.gitProtocolWhitelist + ); + } catch (error) { + this.log.error(`Validate git url ${url} error.`); + this.log.error(error); + return { + uri: url, + // Return a null repo for invalid git url. + repo: null, + }; + } + + this.log.info(`Execute clone job for ${url}`); + const repoService = this.repoServiceFactory.newInstance( + this.serverOptions.repoPath, + this.serverOptions.credsPath, + this.log, + this.serverOptions.security.enableGitCertCheck + ); + const repo = RepositoryUtils.buildRepository(url); + return await repoService.clone(repo, (progress: number, cloneProgress?: CloneProgress) => { + job.payload.uri = repo.uri; + this.updateProgress(job, progress, undefined, cloneProgress); + }); + } + + public async onJobCompleted(job: Job, res: CloneWorkerResult) { + this.log.info(`Clone job done for ${res.repo.uri}`); + await super.onJobCompleted(job, res); + + // Throw out a repository index request after 1 second. + return delay(async () => { + const payload = { + uri: res.repo.uri, + revision: res.repo.revision, + }; + await this.indexWorker.enqueueJob(payload, {}); + }, 1000); + } + + public async onJobEnqueued(job: Job) { + const { url } = job.payload; + const repo = RepositoryUtils.buildRepository(url); + const progress: CloneWorkerProgress = { + uri: repo.uri, + progress: WorkerReservedProgress.INIT, + timestamp: new Date(), + }; + return await this.objectClient.setRepositoryGitStatus(repo.uri, progress); + } + + public async onJobExecutionError(res: any) { + // The payload of clone job won't have the `uri`, but only with `url`. + const url = res.job.payload.url; + const repo = RepositoryUtils.buildRepository(url); + res.job.payload.uri = repo.uri; + return await super.onJobExecutionError(res); + } + + public async onJobTimeOut(res: any) { + // The payload of clone job won't have the `uri`, but only with `url`. + const url = res.job.payload.url; + const repo = RepositoryUtils.buildRepository(url); + res.job.payload.uri = repo.uri; + return await super.onJobTimeOut(res); + } +} diff --git a/x-pack/plugins/code/server/queue/delete_worker.test.ts b/x-pack/plugins/code/server/queue/delete_worker.test.ts new file mode 100644 index 000000000000000..fc8285cb46fb89d --- /dev/null +++ b/x-pack/plugins/code/server/queue/delete_worker.test.ts @@ -0,0 +1,159 @@ +/* + * 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 sinon from 'sinon'; +import { EsClient, Esqueue } from '../lib/esqueue'; + +import { Logger } from '../log'; +import { LspService } from '../lsp/lsp_service'; +import { RepositoryServiceFactory } from '../repository_service_factory'; +import { ServerOptions } from '../server_options'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { CancellationSerivce } from './cancellation_service'; +import { DeleteWorker } from './delete_worker'; + +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esQueue = {}; + +afterEach(() => { + sinon.restore(); +}); + +test('Execute delete job.', async () => { + // Setup RepositoryService + const removeSpy = sinon.fake.returns(Promise.resolve()); + const repoService = { + remove: emptyAsyncFunc, + }; + repoService.remove = removeSpy; + const repoServiceFactory = { + newInstance: (): void => { + return; + }, + }; + const newInstanceSpy = sinon.fake.returns(repoService); + repoServiceFactory.newInstance = newInstanceSpy; + + // Setup CancellationService + const cancelIndexJobSpy = sinon.spy(); + const cancellationService = { + cancelIndexJob: emptyAsyncFunc, + }; + cancellationService.cancelIndexJob = cancelIndexJobSpy; + + // Setup EsClient + const deleteSpy = sinon.fake.returns(Promise.resolve()); + const esClient = { + indices: { + delete: emptyAsyncFunc, + }, + }; + esClient.indices.delete = deleteSpy; + + // Setup LspService + const deleteWorkspaceSpy = sinon.fake.returns(Promise.resolve()); + const lspService = { + deleteWorkspace: emptyAsyncFunc, + }; + lspService.deleteWorkspace = deleteWorkspaceSpy; + + const deleteWorker = new DeleteWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + { + security: { + enableGitCertCheck: false, + }, + } as ServerOptions, + (cancellationService as any) as CancellationSerivce, + (lspService as any) as LspService, + (repoServiceFactory as any) as RepositoryServiceFactory + ); + + await deleteWorker.executeJob({ + payload: { + uri: 'github.com/elastic/kibana', + }, + options: {}, + timestamp: 0, + }); + + expect(cancelIndexJobSpy.calledOnce).toBeTruthy(); + + expect(newInstanceSpy.calledOnce).toBeTruthy(); + expect(removeSpy.calledOnce).toBeTruthy(); + + expect(deleteSpy.calledThrice).toBeTruthy(); + + expect(deleteWorkspaceSpy.calledOnce).toBeTruthy(); +}); + +test('On delete job enqueued.', async () => { + // Setup EsClient + const indexSpy = sinon.fake.returns(Promise.resolve()); + const esClient = { + index: emptyAsyncFunc, + }; + esClient.index = indexSpy; + + const deleteWorker = new DeleteWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + {} as ServerOptions, + {} as CancellationSerivce, + {} as LspService, + {} as RepositoryServiceFactory + ); + + await deleteWorker.onJobEnqueued({ + payload: { + uri: 'github.com/elastic/kibana', + }, + options: {}, + timestamp: 0, + }); + + expect(indexSpy.calledOnce).toBeTruthy(); +}); + +test('On delete job completed.', async () => { + // Setup EsClient + const updateSpy = sinon.fake.returns(Promise.resolve()); + const esClient = { + update: emptyAsyncFunc, + }; + esClient.update = updateSpy; + + const deleteWorker = new DeleteWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + {} as ServerOptions, + {} as CancellationSerivce, + {} as LspService, + {} as RepositoryServiceFactory + ); + + await deleteWorker.onJobCompleted( + { + payload: { + uri: 'github.com/elastic/kibana', + }, + options: {}, + timestamp: 0, + }, + { + uri: 'github.com/elastic/kibana', + } + ); + + // Nothing is called. + expect(updateSpy.notCalled).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/queue/delete_worker.ts b/x-pack/plugins/code/server/queue/delete_worker.ts new file mode 100644 index 000000000000000..0b306f1ecc83f42 --- /dev/null +++ b/x-pack/plugins/code/server/queue/delete_worker.ts @@ -0,0 +1,147 @@ +/* + * 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 { RepositoryUri, WorkerReservedProgress } from '../../model'; +import { WorkerProgress } from '../../model/repository'; +import { DocumentIndexName, ReferenceIndexName, SymbolIndexName } from '../indexer/schema'; +import { EsClient, Esqueue } from '../lib/esqueue'; +import { Logger } from '../log'; +import { LspService } from '../lsp/lsp_service'; +import { RepositoryServiceFactory } from '../repository_service_factory'; +import { RepositoryObjectClient } from '../search'; +import { ServerOptions } from '../server_options'; +import { AbstractWorker } from './abstract_worker'; +import { CancellationSerivce } from './cancellation_service'; +import { Job } from './job'; + +export class DeleteWorker extends AbstractWorker { + public id: string = 'delete'; + private objectClient: RepositoryObjectClient; + + constructor( + protected readonly queue: Esqueue, + protected readonly log: Logger, + protected readonly client: EsClient, + protected readonly serverOptions: ServerOptions, + private readonly cancellationService: CancellationSerivce, + private readonly lspService: LspService, + private readonly repoServiceFactory: RepositoryServiceFactory + ) { + super(queue, log); + this.objectClient = new RepositoryObjectClient(this.client); + } + + public async executeJob(job: Job) { + const { uri } = job.payload; + + // 1. Cancel running workers + // TODO: Add support for clone/update worker. + this.cancellationService.cancelIndexJob(uri); + + // 2. Delete repository on local fs. + const repoService = this.repoServiceFactory.newInstance( + this.serverOptions.repoPath, + this.serverOptions.credsPath, + this.log, + this.serverOptions.security.enableGitCertCheck + ); + const deleteRepoPromise = this.deletePromiseWrapper(repoService.remove(uri), 'git data', uri); + + // 3. Delete ES indices and aliases + const deleteSymbolESIndexPromise = this.deletePromiseWrapper( + this.client.indices.delete({ index: `${SymbolIndexName(uri)}*` }), + 'symbol ES index', + uri + ); + + const deleteReferenceESIndexPromise = this.deletePromiseWrapper( + this.client.indices.delete({ index: `${ReferenceIndexName(uri)}*` }), + 'reference ES index', + uri + ); + + const deleteWorkspacePromise = this.deletePromiseWrapper( + this.lspService.deleteWorkspace(uri), + 'workspace', + uri + ); + + try { + await Promise.all([ + deleteRepoPromise, + deleteSymbolESIndexPromise, + deleteReferenceESIndexPromise, + deleteWorkspacePromise, + ]); + + // 4. Delete the document index and alias where the repository document and all status reside, + // so that you won't be able to import the same repositories until they are + // fully removed. + await this.deletePromiseWrapper( + this.client.indices.delete({ index: `${DocumentIndexName(uri)}*` }), + 'document ES index', + uri + ); + + return { + uri, + res: true, + }; + } catch (error) { + this.log.error(`Delete repository ${uri} error.`); + this.log.error(error); + return { + uri, + res: false, + }; + } + } + + public async onJobEnqueued(job: Job) { + const repoUri = job.payload.uri; + const progress: WorkerProgress = { + uri: repoUri, + progress: WorkerReservedProgress.INIT, + timestamp: new Date(), + }; + return await this.objectClient.setRepositoryDeleteStatus(repoUri, progress); + } + + public async updateProgress(job: Job, progress: number) { + const { uri } = job.payload; + const p: WorkerProgress = { + uri, + progress, + timestamp: new Date(), + }; + if (progress !== WorkerReservedProgress.COMPLETED) { + return await this.objectClient.updateRepositoryDeleteStatus(uri, p); + } + } + + protected async getTimeoutMs(_: any) { + return ( + moment.duration(1, 'hour').asMilliseconds() + moment.duration(10, 'minutes').asMilliseconds() + ); + } + + private deletePromiseWrapper( + promise: Promise, + type: string, + repoUri: RepositoryUri + ): Promise { + return promise + .then(() => { + this.log.info(`Delete ${type} of repository ${repoUri} done.`); + }) + .catch((error: Error) => { + this.log.error(`Delete ${type} of repository ${repoUri} error.`); + this.log.error(error); + }); + } +} diff --git a/x-pack/plugins/code/server/queue/index.ts b/x-pack/plugins/code/server/queue/index.ts new file mode 100644 index 000000000000000..35de744d53c9b5d --- /dev/null +++ b/x-pack/plugins/code/server/queue/index.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 * from './cancellation_service'; +export * from './clone_worker'; +export * from './delete_worker'; +export * from './index_worker'; +export * from './update_worker'; +export * from './worker'; diff --git a/x-pack/plugins/code/server/queue/index_worker.test.ts b/x-pack/plugins/code/server/queue/index_worker.test.ts new file mode 100644 index 000000000000000..9aab3130a46da31 --- /dev/null +++ b/x-pack/plugins/code/server/queue/index_worker.test.ts @@ -0,0 +1,389 @@ +/* + * 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 sinon from 'sinon'; + +import { WorkerReservedProgress } from '../../model'; +import { IndexerFactory } from '../indexer'; +import { RepositoryLspIndexStatusReservedField } from '../indexer/schema'; +import { CancellationToken, EsClient, Esqueue } from '../lib/esqueue'; +import { Logger } from '../log'; +import { ServerOptions } from '../server_options'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { CancellationSerivce } from './cancellation_service'; +import { IndexWorker } from './index_worker'; + +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esQueue = {}; + +afterEach(() => { + sinon.restore(); +}); + +test('Execute index job.', async () => { + // Setup CancellationService + const cancelIndexJobSpy = sinon.spy(); + const registerIndexJobTokenSpy = sinon.spy(); + const cancellationService = { + cancelIndexJob: emptyAsyncFunc, + registerIndexJobToken: emptyAsyncFunc, + }; + cancellationService.cancelIndexJob = cancelIndexJobSpy; + cancellationService.registerIndexJobToken = registerIndexJobTokenSpy; + + // Setup EsClient + const getSpy = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryLspIndexStatusReservedField]: { + uri: 'github.com/Microsoft/TypeScript-Node-Starter', + progress: WorkerReservedProgress.COMPLETED, + timestamp: new Date(), + revision: 'abcdefg', + }, + }, + }) + ); + const esClient = { + get: emptyAsyncFunc, + }; + esClient.get = getSpy; + + // Setup IndexerFactory + const cancelSpy = sinon.spy(); + const startSpy = sinon.fake.returns(new Map()); + const indexer = { + cancel: emptyAsyncFunc, + start: emptyAsyncFunc, + }; + indexer.cancel = cancelSpy; + indexer.start = startSpy; + const createSpy = sinon.fake.returns(indexer); + const indexerFactory = { + create: emptyAsyncFunc, + }; + indexerFactory.create = createSpy; + + const cToken = new CancellationToken(); + + const indexWorker = new IndexWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + [(indexerFactory as any) as IndexerFactory], + {} as ServerOptions, + (cancellationService as any) as CancellationSerivce + ); + + await indexWorker.executeJob({ + payload: { + uri: 'github.com/elastic/kibana', + }, + options: {}, + cancellationToken: cToken, + timestamp: 0, + }); + + expect(cancelIndexJobSpy.calledOnce).toBeTruthy(); + expect(getSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledOnce).toBeTruthy(); + expect(startSpy.calledOnce).toBeTruthy(); + expect(cancelSpy.notCalled).toBeTruthy(); +}); + +test('Execute index job and then cancel.', async () => { + // Setup CancellationService + const cancelIndexJobSpy = sinon.spy(); + const registerIndexJobTokenSpy = sinon.spy(); + const cancellationService = { + cancelIndexJob: emptyAsyncFunc, + registerIndexJobToken: emptyAsyncFunc, + }; + cancellationService.cancelIndexJob = cancelIndexJobSpy; + cancellationService.registerIndexJobToken = registerIndexJobTokenSpy; + + // Setup EsClient + const getSpy = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryLspIndexStatusReservedField]: { + uri: 'github.com/Microsoft/TypeScript-Node-Starter', + progress: WorkerReservedProgress.COMPLETED, + timestamp: new Date(), + revision: 'abcdefg', + }, + }, + }) + ); + const esClient = { + get: emptyAsyncFunc, + }; + esClient.get = getSpy; + + // Setup IndexerFactory + const cancelSpy = sinon.spy(); + const startSpy = sinon.fake.returns(new Map()); + const indexer = { + cancel: emptyAsyncFunc, + start: emptyAsyncFunc, + }; + indexer.cancel = cancelSpy; + indexer.start = startSpy; + const createSpy = sinon.fake.returns(indexer); + const indexerFactory = { + create: emptyAsyncFunc, + }; + indexerFactory.create = createSpy; + + const cToken = new CancellationToken(); + + const indexWorker = new IndexWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + [(indexerFactory as any) as IndexerFactory], + {} as ServerOptions, + (cancellationService as any) as CancellationSerivce + ); + + await indexWorker.executeJob({ + payload: { + uri: 'github.com/elastic/kibana', + }, + options: {}, + cancellationToken: cToken, + timestamp: 0, + }); + + // Cancel the index job. + cToken.cancel(); + + expect(cancelIndexJobSpy.calledOnce).toBeTruthy(); + expect(getSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledOnce).toBeTruthy(); + expect(startSpy.calledOnce).toBeTruthy(); + // Then the the cancel function of the indexer should be called. + expect(cancelSpy.calledOnce).toBeTruthy(); +}); + +test('Index job skipped/deduplicated if revision matches', async () => { + // Setup CancellationService + const cancelIndexJobSpy = sinon.spy(); + const registerIndexJobTokenSpy = sinon.spy(); + const cancellationService = { + cancelIndexJob: emptyAsyncFunc, + registerIndexJobToken: emptyAsyncFunc, + }; + cancellationService.cancelIndexJob = cancelIndexJobSpy; + cancellationService.registerIndexJobToken = registerIndexJobTokenSpy; + + // Setup EsClient + const getSpy = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryLspIndexStatusReservedField]: { + uri: 'github.com/elastic/kibana', + progress: 50, + timestamp: new Date(), + revision: 'abcdefg', + indexProgress: {}, + }, + }, + }) + ); + const esClient = { + get: emptyAsyncFunc, + }; + esClient.get = getSpy; + + // Setup IndexerFactory + const cancelSpy = sinon.spy(); + const startSpy = sinon.fake.returns(new Map()); + const indexer = { + cancel: emptyAsyncFunc, + start: emptyAsyncFunc, + }; + indexer.cancel = cancelSpy; + indexer.start = startSpy; + const createSpy = sinon.fake.returns(indexer); + const indexerFactory = { + create: emptyAsyncFunc, + }; + indexerFactory.create = createSpy; + + const cToken = new CancellationToken(); + + const indexWorker = new IndexWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + [(indexerFactory as any) as IndexerFactory], + {} as ServerOptions, + (cancellationService as any) as CancellationSerivce + ); + + await indexWorker.executeJob({ + payload: { + uri: 'github.com/elastic/kibana', + revision: 'abcdefg', + }, + options: {}, + cancellationToken: cToken, + timestamp: 0, + }); + + expect(getSpy.calledOnce).toBeTruthy(); + expect(cancelIndexJobSpy.notCalled).toBeTruthy(); + expect(createSpy.notCalled).toBeTruthy(); + expect(startSpy.notCalled).toBeTruthy(); + expect(cancelSpy.notCalled).toBeTruthy(); +}); + +test('Index job continue if revision matches and checkpoint found', async () => { + // Setup CancellationService + const cancelIndexJobSpy = sinon.spy(); + const registerIndexJobTokenSpy = sinon.spy(); + const cancellationService = { + cancelIndexJob: emptyAsyncFunc, + registerIndexJobToken: emptyAsyncFunc, + }; + cancellationService.cancelIndexJob = cancelIndexJobSpy; + cancellationService.registerIndexJobToken = registerIndexJobTokenSpy; + + // Setup EsClient + const getSpy = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryLspIndexStatusReservedField]: { + uri: 'github.com/elastic/kibana', + progress: 50, + timestamp: new Date(), + revision: 'abcdefg', + indexProgress: { + checkpoint: { + repoUri: 'github.com/elastic/kibana', + filePath: 'foo/bar.js', + revision: 'abcdefg', + }, + }, + }, + }, + }) + ); + const esClient = { + get: emptyAsyncFunc, + }; + esClient.get = getSpy; + + // Setup IndexerFactory + const cancelSpy = sinon.spy(); + const startSpy = sinon.fake.returns(new Map()); + const indexer = { + cancel: emptyAsyncFunc, + start: emptyAsyncFunc, + }; + indexer.cancel = cancelSpy; + indexer.start = startSpy; + const createSpy = sinon.fake.returns(indexer); + const indexerFactory = { + create: emptyAsyncFunc, + }; + indexerFactory.create = createSpy; + + const cToken = new CancellationToken(); + + const indexWorker = new IndexWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + [(indexerFactory as any) as IndexerFactory], + {} as ServerOptions, + (cancellationService as any) as CancellationSerivce + ); + + await indexWorker.executeJob({ + payload: { + uri: 'github.com/elastic/kibana', + revision: 'abcdefg', + }, + options: {}, + cancellationToken: cToken, + timestamp: 0, + }); + + expect(getSpy.calledOnce).toBeTruthy(); + // the rest of the index worker logic after the checkpoint handling + // should be executed. + expect(cancelIndexJobSpy.calledOnce).toBeTruthy(); + expect(createSpy.calledOnce).toBeTruthy(); + expect(startSpy.calledOnce).toBeTruthy(); + expect(cancelSpy.notCalled).toBeTruthy(); +}); + +test('On index job enqueued.', async () => { + // Setup EsClient + const indexSpy = sinon.fake.returns(Promise.resolve()); + const esClient = { + index: emptyAsyncFunc, + }; + esClient.index = indexSpy; + + const indexWorker = new IndexWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + [], + {} as ServerOptions, + {} as CancellationSerivce + ); + + await indexWorker.onJobEnqueued({ + payload: { + uri: 'github.com/elastic/kibana', + }, + options: {}, + timestamp: 0, + }); + + expect(indexSpy.calledOnce).toBeTruthy(); +}); + +test('On index job completed.', async () => { + // Setup EsClient + const updateSpy = sinon.fake.returns(Promise.resolve()); + const esClient = { + update: emptyAsyncFunc, + }; + esClient.update = updateSpy; + + const indexWorker = new IndexWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + [], + {} as ServerOptions, + {} as CancellationSerivce + ); + + await indexWorker.onJobCompleted( + { + payload: { + uri: 'github.com/elastic/kibana', + }, + options: {}, + timestamp: 0, + }, + { + uri: 'github.com/elastic/kibana', + revision: 'master', + stats: new Map(), + } + ); + + expect(updateSpy.calledTwice).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/queue/index_worker.ts b/x-pack/plugins/code/server/queue/index_worker.ts new file mode 100644 index 000000000000000..37c049eb1b46153 --- /dev/null +++ b/x-pack/plugins/code/server/queue/index_worker.ts @@ -0,0 +1,202 @@ +/* + * 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 { + IndexProgress, + IndexRequest, + IndexStats, + IndexWorkerProgress, + IndexWorkerResult, + RepositoryUri, + WorkerProgress, + WorkerReservedProgress, +} from '../../model'; +import { GitOperations } from '../git_operations'; +import { IndexerFactory } from '../indexer'; +import { EsClient, Esqueue } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositoryObjectClient } from '../search'; +import { ServerOptions } from '../server_options'; +import { aggregateIndexStats } from '../utils/index_stats_aggregator'; +import { AbstractWorker } from './abstract_worker'; +import { CancellationSerivce } from './cancellation_service'; +import { Job } from './job'; + +export class IndexWorker extends AbstractWorker { + public id: string = 'index'; + private objectClient: RepositoryObjectClient; + + constructor( + protected readonly queue: Esqueue, + protected readonly log: Logger, + protected readonly client: EsClient, + protected readonly indexerFactories: IndexerFactory[], + protected readonly options: ServerOptions, + private readonly cancellationService: CancellationSerivce + ) { + super(queue, log); + + this.objectClient = new RepositoryObjectClient(this.client); + } + + public async executeJob(job: Job) { + const { payload, cancellationToken } = job; + const { uri, revision } = payload; + const indexerNumber = this.indexerFactories.length; + + const workerProgress = (await this.objectClient.getRepositoryLspIndexStatus( + uri + )) as IndexWorkerProgress; + let checkpointReq: IndexRequest | undefined; + if (workerProgress) { + // There exist an ongoing index process + const { + uri: currentUri, + revision: currentRevision, + indexProgress: currentIndexProgress, + progress, + } = workerProgress; + + checkpointReq = currentIndexProgress && currentIndexProgress.checkpoint; + if ( + !checkpointReq && + progress > WorkerReservedProgress.INIT && + progress < WorkerReservedProgress.COMPLETED && + currentUri === uri && + currentRevision === revision + ) { + // If + // * no checkpoint exist (undefined or empty string) + // * index progress is ongoing + // * the uri and revision match the current job + // Then we can safely dedup this index job request. + this.log.info(`Index job skipped for ${uri} at revision ${revision}`); + return { + uri, + revision, + }; + } + } + + // Binding the index cancellation logic + this.cancellationService.cancelIndexJob(uri); + const indexPromises: Array> = this.indexerFactories.map( + async (indexerFactory: IndexerFactory, index: number) => { + const indexer = await indexerFactory.create(uri, revision); + if (!indexer) { + this.log.info(`Failed to create indexer for ${uri}`); + return new Map(); // return an empty map as stats. + } + + if (cancellationToken) { + cancellationToken.on(() => { + indexer.cancel(); + }); + this.cancellationService.registerIndexJobToken(uri, cancellationToken); + } + const progressReporter = this.getProgressReporter(uri, revision, index, indexerNumber); + return indexer.start(progressReporter, checkpointReq); + } + ); + const stats: IndexStats[] = await Promise.all(indexPromises); + const res: IndexWorkerResult = { + uri, + revision, + stats: aggregateIndexStats(stats), + }; + this.log.info(`Index worker finished with stats: ${JSON.stringify([...res.stats])}`); + return res; + } + + public async onJobEnqueued(job: Job) { + const { uri, revision } = job.payload; + const progress: WorkerProgress = { + uri, + progress: WorkerReservedProgress.INIT, + timestamp: new Date(), + revision, + }; + return await this.objectClient.setRepositoryLspIndexStatus(uri, progress); + } + + public async onJobCompleted(job: Job, res: IndexWorkerResult) { + await super.onJobCompleted(job, res); + const { uri, revision } = job.payload; + try { + return await this.objectClient.updateRepository(uri, { indexedRevision: revision }); + } catch (error) { + this.log.error(`Update indexed revision in repository object error.`); + this.log.error(error); + } + } + + public async updateProgress(job: Job, progress: number) { + const { uri } = job.payload; + let p: any = { + uri, + progress, + timestamp: new Date(), + }; + if ( + progress === WorkerReservedProgress.COMPLETED || + progress === WorkerReservedProgress.ERROR || + progress === WorkerReservedProgress.TIMEOUT + ) { + // Reset the checkpoint if necessary. + p = { + ...p, + indexProgress: { + checkpoint: null, + }, + }; + } + try { + return await this.objectClient.updateRepositoryLspIndexStatus(uri, p); + } catch (error) { + this.log.error(`Update index progress error.`); + this.log.error(error); + } + } + + protected async getTimeoutMs(payload: any) { + try { + const gitOperator = new GitOperations(this.options.repoPath); + const totalCount = await gitOperator.countRepoFiles(payload.uri, 'head'); + let timeout = moment.duration(1, 'hour').asMilliseconds(); + if (totalCount > 0) { + // timeout = ln(file_count) in hour + // e.g. 10 files -> 2.3 hours, 100 files -> 4.6 hours, 1000 -> 6.9 hours, 10000 -> 9.2 hours + timeout = moment.duration(Math.log(totalCount), 'hour').asMilliseconds(); + } + this.log.info(`Set index job timeout to be ${timeout} ms.`); + return timeout; + } catch (error) { + this.log.error(`Get repo file total count error.`); + this.log.error(error); + throw error; + } + } + + private getProgressReporter( + repoUri: RepositoryUri, + revision: string, + index: number, + total: number + ) { + return async (progress: IndexProgress) => { + const p: IndexWorkerProgress = { + uri: repoUri, + progress: progress.percentage, + timestamp: new Date(), + revision, + indexProgress: progress, + }; + return await this.objectClient.setRepositoryLspIndexStatus(repoUri, p); + }; + } +} diff --git a/x-pack/plugins/code/server/queue/job.ts b/x-pack/plugins/code/server/queue/job.ts new file mode 100644 index 000000000000000..7ca328c128e6591 --- /dev/null +++ b/x-pack/plugins/code/server/queue/job.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 { CancellationToken } from '../lib/esqueue'; + +export interface Job { + payload: any; + options: any; + timestamp: number; + cancellationToken?: CancellationToken; +} diff --git a/x-pack/plugins/code/server/queue/update_worker.test.ts b/x-pack/plugins/code/server/queue/update_worker.test.ts new file mode 100644 index 000000000000000..9eceef62a287a5d --- /dev/null +++ b/x-pack/plugins/code/server/queue/update_worker.test.ts @@ -0,0 +1,62 @@ +/* + * 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 sinon from 'sinon'; +import { EsClient, Esqueue } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositoryServiceFactory } from '../repository_service_factory'; +import { ServerOptions } from '../server_options'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { UpdateWorker } from './update_worker'; + +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esClient = {}; +const esQueue = {}; + +afterEach(() => { + sinon.restore(); +}); + +test('Execute update job', async () => { + // Setup RepositoryService + const updateSpy = sinon.spy(); + const repoService = { + update: emptyAsyncFunc, + }; + repoService.update = updateSpy; + const repoServiceFactory = { + newInstance: (): void => { + return; + }, + }; + const newInstanceSpy = sinon.fake.returns(repoService); + repoServiceFactory.newInstance = newInstanceSpy; + + const updateWorker = new UpdateWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + { + security: { + enableGitCertCheck: false, + }, + } as ServerOptions, + (repoServiceFactory as any) as RepositoryServiceFactory + ); + + await updateWorker.executeJob({ + payload: { + uri: 'mockrepo', + }, + options: {}, + timestamp: 0, + }); + + expect(newInstanceSpy.calledOnce).toBeTruthy(); + expect(updateSpy.calledOnce).toBeTruthy(); +}); diff --git a/x-pack/plugins/code/server/queue/update_worker.ts b/x-pack/plugins/code/server/queue/update_worker.ts new file mode 100644 index 000000000000000..c5be67439989bb7 --- /dev/null +++ b/x-pack/plugins/code/server/queue/update_worker.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 { CloneWorkerResult, Repository } from '../../model'; +import { EsClient, Esqueue } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositoryServiceFactory } from '../repository_service_factory'; +import { ServerOptions } from '../server_options'; +import { AbstractGitWorker } from './abstract_git_worker'; +import { Job } from './job'; + +export class UpdateWorker extends AbstractGitWorker { + public id: string = 'update'; + + constructor( + queue: Esqueue, + protected readonly log: Logger, + protected readonly client: EsClient, + protected readonly serverOptions: ServerOptions, + protected readonly repoServiceFactory: RepositoryServiceFactory + ) { + super(queue, log, client, serverOptions); + } + + public async executeJob(job: Job) { + const repo: Repository = job.payload; + this.log.info(`Execute update job for ${repo.uri}`); + const repoService = this.repoServiceFactory.newInstance( + this.serverOptions.repoPath, + this.serverOptions.credsPath, + this.log, + this.serverOptions.security.enableGitCertCheck + ); + return await repoService.update(repo); + } + + public async onJobCompleted(job: Job, res: CloneWorkerResult) { + this.log.info(`Update job done for ${job.payload.uri}`); + return await super.onJobCompleted(job, res); + } +} diff --git a/x-pack/plugins/code/server/queue/worker.ts b/x-pack/plugins/code/server/queue/worker.ts new file mode 100644 index 000000000000000..a3ecff215eecee9 --- /dev/null +++ b/x-pack/plugins/code/server/queue/worker.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 { Job } from './job'; + +export interface Worker { + createJob(payload: any, options: any): Promise; + executeJob(job: Job): void; + enqueueJob(payload: any, options: any): void; + + onJobEnqueued(res: any): void; + onJobCompleted(job: Job, res: any): void; + onJobExecutionError(res: any): void; + onJobTimeOut(res: any): void; + + updateProgress(job: Job, progress: number): void; +} diff --git a/x-pack/plugins/code/server/repository_config_controller.ts b/x-pack/plugins/code/server/repository_config_controller.ts new file mode 100644 index 000000000000000..b3766d5d04ee586 --- /dev/null +++ b/x-pack/plugins/code/server/repository_config_controller.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 { parseLspUrl } from '../common/uri_util'; +import { RepositoryConfig } from '../model'; +import { EsClient } from '../server/lib/esqueue'; +import { RepositoryObjectClient } from './search'; + +export class RepositoryConfigController { + private repositoryConfigCache: { [repoUri: string]: RepositoryConfig } = {}; + private repoObjectClient: RepositoryObjectClient; + + constructor(readonly esClient: EsClient) { + this.repoObjectClient = new RepositoryObjectClient(esClient); + } + + public async isLanguageDisabled(uri: string, lang: string): Promise { + const { repoUri } = parseLspUrl(uri)!; + let repoConfig = this.repositoryConfigCache[repoUri]; + + if (!repoConfig) { + try { + repoConfig = await this.repoObjectClient.getRepositoryConfig(repoUri); + } catch (err) { + return false; + } + } + + if (lang === 'go' && repoConfig.disableGo === true) { + return true; + } + if (lang === 'java' && repoConfig.disableJava === true) { + return true; + } + if (lang === 'typescript' && repoConfig.disableTypescript === true) { + return true; + } + return false; + } + + public async resetConfigCache(repoUri: string) { + delete this.repositoryConfigCache[repoUri]; + } +} diff --git a/x-pack/plugins/code/server/repository_service.ts b/x-pack/plugins/code/server/repository_service.ts new file mode 100644 index 000000000000000..8ac7408c5a45b35 --- /dev/null +++ b/x-pack/plugins/code/server/repository_service.ts @@ -0,0 +1,263 @@ +/* + * 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, { RemoteCallbacks } from '@elastic/nodegit'; +import del from 'del'; +import fs from 'fs'; +import mkdirp from 'mkdirp'; +import path from 'path'; +import { RepositoryUtils } from '../common/repository_utils'; +import { + CloneProgress, + CloneWorkerResult, + DeleteWorkerResult, + Repository, + UpdateWorkerResult, +} from '../model'; +import { Logger } from './log'; + +export type CloneProgressHandler = (progress: number, cloneProgress?: CloneProgress) => void; + +const SSH_AUTH_ERROR = new Error('Failed to authenticate SSH session'); + +// This is the service for any kind of repository handling, e.g. clone, update, delete, etc. +export class RepositoryService { + constructor( + private readonly repoVolPath: string, + private readonly credsPath: string, + private readonly log: Logger, + private readonly enableGitCertCheck: boolean + ) {} + + public async clone(repo: Repository, handler?: CloneProgressHandler): Promise { + if (!repo) { + throw new Error(`Invalid repository.`); + } else { + const localPath = RepositoryUtils.repositoryLocalPath(this.repoVolPath, repo.uri); + if (fs.existsSync(localPath)) { + this.log.info(`Repository exist in local path. Do update instead of clone.`); + try { + // Do update instead of clone if the local repo exists. + const updateRes = await this.update(repo); + return { + uri: repo.uri, + repo: { + ...repo, + defaultBranch: updateRes.branch, + revision: updateRes.revision, + }, + }; + } catch (error) { + // If failed to update the current git repo living in the disk, clean up the local git repo and + // move on with the clone. + await this.remove(repo.uri); + } + } else { + const parentDir = path.dirname(localPath); + // on windows, git clone will failed if parent folder is not exists; + await new Promise((resolve, reject) => + mkdirp(parentDir, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }) + ); + } + // Go head with the actual clone. + if (repo.protocol === 'ssh') { + return this.tryWithKeys(key => this.doClone(repo, localPath, handler, key)); + } else { + return await this.doClone(repo, localPath, handler); + } + } + } + + public async remove(uri: string): Promise { + const localPath = RepositoryUtils.repositoryLocalPath(this.repoVolPath, uri); + try { + // For now, just `rm -rf` + await del([localPath], { force: true }); + this.log.info(`Remove local repository ${uri} done.`); + return { + uri, + res: true, + }; + } catch (error) { + this.log.error(`Remove local repository ${uri} error: ${error}.`); + throw error; + } + } + public async update(repo: Repository): Promise { + if (repo.protocol === 'ssh') { + return await this.tryWithKeys(key => this.doUpdate(repo.uri, key)); + } else { + return await this.doUpdate(repo.uri); + } + } + public async doUpdate(uri: string, key?: string): Promise { + const localPath = RepositoryUtils.repositoryLocalPath(this.repoVolPath, uri); + try { + const repo = await Git.Repository.open(localPath); + const cbs: RemoteCallbacks = { + credentials: this.credentialFunc(key), + }; + // Ignore cert check on testing environment. + if (!this.enableGitCertCheck) { + cbs.certificateCheck = () => { + // Ignore cert check failures. + return 0; + }; + } + await repo.fetchAll({ + callbacks: cbs, + }); + // TODO(mengwei): deal with the case when the default branch has changed. + const currentBranch = await repo.getCurrentBranch(); + const currentBranchName = currentBranch.shorthand(); + const originBranchName = `origin/${currentBranchName}`; + const originRef = await repo.getReference(originBranchName); + const headRef = await repo.getReference(currentBranchName); + if (!originRef.target().equal(headRef.target())) { + await headRef.setTarget(originRef.target(), 'update'); + } + const headCommit = await repo.getHeadCommit(); + this.log.debug(`Update repository to revision ${headCommit.sha()}`); + return { + uri, + branch: currentBranchName, + revision: headCommit.sha(), + }; + } catch (error) { + if (error.message && error.message.startsWith(SSH_AUTH_ERROR.message)) { + throw SSH_AUTH_ERROR; + } else { + const msg = `update repository ${uri} error: ${error}`; + this.log.error(msg); + throw new Error(msg); + } + } + } + + /** + * read credentials dir, try using each privateKey until action is successful + * @param action + */ + private async tryWithKeys(action: (key: string) => Promise): Promise { + const files = fs.existsSync(this.credsPath) + ? new Set(fs.readdirSync(this.credsPath)) + : new Set(); + for (const f of files) { + if (f.endsWith('.pub')) { + const privateKey = f.slice(0, f.length - 4); + if (files.has(privateKey)) { + try { + this.log.debug(`try with key ${privateKey}`); + return await action(privateKey); + } catch (e) { + if (e !== SSH_AUTH_ERROR) { + throw e; + } + // continue to try another key + } + } + } + } + throw SSH_AUTH_ERROR; + } + + private async doClone( + repo: Repository, + localPath: string, + handler?: CloneProgressHandler, + keyFile?: string + ) { + try { + const cbs: RemoteCallbacks = { + transferProgress: { + // Make the progress update less frequent to avoid too many + // concurrently update of git status in elasticsearch. + throttle: 1000, + callback: (stats: any) => { + if (handler) { + const progress = + (100 * (stats.receivedObjects() + stats.indexedObjects())) / + (stats.totalObjects() * 2); + const cloneProgress = { + isCloned: false, + receivedObjects: stats.receivedObjects(), + indexedObjects: stats.indexedObjects(), + totalObjects: stats.totalObjects(), + localObjects: stats.localObjects(), + totalDeltas: stats.totalDeltas(), + indexedDeltas: stats.indexedDeltas(), + receivedBytes: stats.receivedBytes(), + }; + handler(progress, cloneProgress); + } + }, + } as any, + credentials: this.credentialFunc(keyFile), + }; + // Ignore cert check on testing environment. + if (!this.enableGitCertCheck) { + cbs.certificateCheck = () => { + // Ignore cert check failures. + return 0; + }; + } + const gitRepo = await Git.Clone.clone(repo.url, localPath, { + bare: 1, + fetchOpts: { + callbacks: cbs, + }, + }); + const headCommit = await gitRepo.getHeadCommit(); + const headRevision = headCommit.sha(); + const currentBranch = await gitRepo.getCurrentBranch(); + const currentBranchName = currentBranch.shorthand(); + this.log.info( + `Clone repository from ${ + repo.url + } done with head revision ${headRevision} and default branch ${currentBranchName}` + ); + return { + uri: repo.uri, + repo: { + ...repo, + defaultBranch: currentBranchName, + revision: headRevision, + }, + }; + } catch (error) { + if (error.message && error.message.startsWith(SSH_AUTH_ERROR.message)) { + throw SSH_AUTH_ERROR; + } else { + const msg = `Clone repository from ${repo.url} error.`; + this.log.error(msg); + this.log.error(error); + throw new Error(error.message); + } + } + } + + private credentialFunc(keyFile: string | undefined) { + return (url: string, userName: string) => { + if (keyFile) { + this.log.debug(`try with key ${path.join(this.credsPath, keyFile)}`); + return Git.Cred.sshKeyNew( + userName, + path.join(this.credsPath, `${keyFile}.pub`), + path.join(this.credsPath, keyFile), + '' + ); + } else { + return Git.Cred.defaultNew(); + } + }; + } +} diff --git a/x-pack/plugins/code/server/repository_service_factory.ts b/x-pack/plugins/code/server/repository_service_factory.ts new file mode 100644 index 000000000000000..1980477e9d3f05c --- /dev/null +++ b/x-pack/plugins/code/server/repository_service_factory.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 { Logger } from './log'; +import { RepositoryService } from './repository_service'; + +export class RepositoryServiceFactory { + public newInstance( + repoPath: string, + credsPath: string, + log: Logger, + enableGitCertCheck: boolean + ): RepositoryService { + return new RepositoryService(repoPath, credsPath, log, enableGitCertCheck); + } +} diff --git a/x-pack/plugins/code/server/routes/file.ts b/x-pack/plugins/code/server/routes/file.ts new file mode 100644 index 000000000000000..da915ec577ac01f --- /dev/null +++ b/x-pack/plugins/code/server/routes/file.ts @@ -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 { Reference } from '@elastic/nodegit'; +import { Commit, Revwalk } from '@elastic/nodegit'; +import Boom from 'boom'; +import fileType from 'file-type'; +import hapi, { RequestQuery } from 'hapi'; +import { ReferenceInfo } from '../../model/commit'; +import { + commitInfo, + DEFAULT_TREE_CHILDREN_LIMIT, + GitOperations, + referenceInfo, +} from '../git_operations'; +import { ServerOptions } from '../server_options'; +import { extractLines } from '../utils/buffer'; +import { detectLanguage } from '../utils/detect_language'; +import { CodeServerRouter } from '../security'; + +const TEXT_FILE_LIMIT = 1024 * 1024; // 1mb + +export function fileRoute(server: CodeServerRouter, options: ServerOptions) { + server.route({ + path: '/api/code/repo/{uri*3}/tree/{ref}/{path*}', + method: 'GET', + async handler(req: hapi.Request) { + const fileResolver = new GitOperations(options.repoPath); + const { uri, path, ref } = req.params; + const queries = req.query as RequestQuery; + const limit = queries.limit + ? parseInt(queries.limit as string, 10) + : DEFAULT_TREE_CHILDREN_LIMIT; + const skip = queries.skip ? parseInt(queries.skip as string, 10) : 0; + const depth = queries.depth ? parseInt(queries.depth as string, 10) : 0; + const withParents = 'parents' in queries; + const flatten = 'flatten' in queries; + try { + return await fileResolver.fileTree( + uri, + path, + ref, + skip, + limit, + withParents, + depth, + flatten + ); + } catch (e) { + if (e.isBoom) { + return e; + } else { + return Boom.internal(e.message || e.name); + } + } + }, + }); + + server.route({ + path: '/api/code/repo/{uri*3}/blob/{ref}/{path*}', + method: 'GET', + async handler(req: hapi.Request, h: hapi.ResponseToolkit) { + const fileResolver = new GitOperations(options.repoPath); + const { uri, path, ref } = req.params; + try { + const blob = await fileResolver.fileContent(uri, path, decodeURIComponent(ref)); + if (blob.isBinary()) { + const type = fileType(blob.content()); + if (type && type.mime && type.mime.startsWith('image/')) { + const response = h.response(blob.content()); + response.type(type.mime); + return response; + } else { + // this api will return a empty response with http code 204 + return h + .response('') + .type('application/octet-stream') + .code(204); + } + } else { + const line = (req.query as RequestQuery).line as string; + if (line) { + const [from, to] = line.split(','); + let fromLine = parseInt(from, 10); + let toLine = to === undefined ? fromLine + 1 : parseInt(to, 10); + if (fromLine > toLine) { + [fromLine, toLine] = [toLine, fromLine]; + } + const lines = extractLines(blob.content(), fromLine, toLine); + const lang = await detectLanguage(path, lines); + return h.response(lines).type(`text/${lang || 'plain'}`); + } else if (blob.content().length <= TEXT_FILE_LIMIT) { + const lang = await detectLanguage(path, blob.content()); + return h.response(blob.content()).type(`text/${lang || 'plain'}`); + } else { + return h.response('').type(`text/big`); + } + } + } catch (e) { + if (e.isBoom) { + return e; + } else { + return Boom.internal(e.message || e.name); + } + } + }, + }); + + server.route({ + path: '/app/code/repo/{uri*3}/raw/{ref}/{path*}', + method: 'GET', + async handler(req, h: hapi.ResponseToolkit) { + const fileResolver = new GitOperations(options.repoPath); + const { uri, path, ref } = req.params; + try { + const blob = await fileResolver.fileContent(uri, path, ref); + if (blob.isBinary()) { + return h.response(blob.content()).type('application/octet-stream'); + } else { + return h.response(blob.content()).type('text/plain'); + } + } catch (e) { + if (e.isBoom) { + return e; + } else { + return Boom.internal(e.message || e.name); + } + } + }, + }); + + server.route({ + path: '/api/code/repo/{uri*3}/history/{ref}', + method: 'GET', + handler: historyHandler, + }); + + server.route({ + path: '/api/code/repo/{uri*3}/history/{ref}/{path*}', + method: 'GET', + handler: historyHandler, + }); + + async function historyHandler(req: hapi.Request) { + const gitOperations = new GitOperations(options.repoPath); + const { uri, ref, path } = req.params; + const queries = req.query as RequestQuery; + const count = queries.count ? parseInt(queries.count as string, 10) : 10; + const after = queries.after !== undefined; + try { + const repository = await gitOperations.openRepo(uri); + const commit = await gitOperations.getCommit(repository, decodeURIComponent(ref)); + const walk = repository.createRevWalk(); + walk.sorting(Revwalk.SORT.TIME); + walk.push(commit.id()); + let commits: Commit[]; + if (path) { + // magic number 10000: how many commits at the most to iterate in order to find the commits contains the path + const results = await walk.fileHistoryWalk(path, count, 10000); + commits = results.map(result => result.commit); + } else { + walk.push(commit.id()); + commits = await walk.getCommits(count); + } + if (after && commits.length > 0) { + if (commits[0].id().equal(commit.id())) { + commits = commits.slice(1); + } + } + return commits.map(commitInfo); + } catch (e) { + if (e.isBoom) { + return e; + } else { + return Boom.internal(e.message || e.name); + } + } + } + server.route({ + path: '/api/code/repo/{uri*3}/references', + method: 'GET', + async handler(req, reply) { + const gitOperations = new GitOperations(options.repoPath); + const uri = req.params.uri; + try { + const repository = await gitOperations.openRepo(uri); + const references = await repository.getReferences(Reference.TYPE.DIRECT); + const results: ReferenceInfo[] = await Promise.all(references.map(referenceInfo)); + return results; + } catch (e) { + if (e.isBoom) { + return e; + } else { + return Boom.internal(e.message || e.name); + } + } + }, + }); + + server.route({ + path: '/api/code/repo/{uri*3}/diff/{revision}', + method: 'GET', + async handler(req) { + const gitOperations = new GitOperations(options.repoPath); + const { uri, revision } = req.params; + try { + const diff = await gitOperations.getCommitDiff(uri, revision); + return diff; + } catch (e) { + if (e.isBoom) { + return e; + } else { + return Boom.internal(e.message || e.name); + } + } + }, + }); + + server.route({ + path: '/api/code/repo/{uri*3}/blame/{revision}/{path*}', + method: 'GET', + async handler(req) { + const gitOperations = new GitOperations(options.repoPath); + const { uri, path, revision } = req.params; + try { + const blames = await gitOperations.blame(uri, decodeURIComponent(revision), path); + return blames; + } catch (e) { + if (e.isBoom) { + return e; + } else { + return Boom.internal(e.message || e.name); + } + } + }, + }); +} diff --git a/x-pack/plugins/code/server/routes/install.ts b/x-pack/plugins/code/server/routes/install.ts new file mode 100644 index 000000000000000..4f7ec68d21a71da --- /dev/null +++ b/x-pack/plugins/code/server/routes/install.ts @@ -0,0 +1,74 @@ +/* + * 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 * as Boom from 'boom'; +import { Request } from 'hapi'; +import { InstallationType } from '../../common/installation'; +import { InstallManager } from '../lsp/install_manager'; +import { LanguageServerDefinition, LanguageServers } from '../lsp/language_servers'; +import { LspService } from '../lsp/lsp_service'; +import { CodeServerRouter } from '../security'; + +export function installRoute( + server: CodeServerRouter, + lspService: LspService, + installManager: InstallManager +) { + const kibanaVersion = server.server.config().get('pkg.version') as string; + const status = (def: LanguageServerDefinition) => ({ + name: def.name, + status: lspService.languageServerStatus(def.name), + version: def.version, + build: def.build, + languages: def.languages, + installationType: def.installationType, + downloadUrl: + typeof def.downloadUrl === 'function' ? def.downloadUrl(def, kibanaVersion) : def.downloadUrl, + pluginName: def.pluginName, + }); + + server.route({ + path: '/api/code/install', + handler() { + return LanguageServers.map(status); + }, + method: 'GET', + }); + + server.route({ + path: '/api/code/install/{name}', + handler(req: Request) { + const name = req.params.name; + const def = LanguageServers.find(d => d.name === name); + if (def) { + return status(def); + } else { + return Boom.notFound(`language server ${name} not found.`); + } + }, + method: 'GET', + }); + + server.route({ + path: '/api/code/install/{name}', + requireAdmin: true, + async handler(req: Request) { + const name = req.params.name; + const def = LanguageServers.find(d => d.name === name); + if (def) { + if (def.installationType === InstallationType.Plugin) { + return Boom.methodNotAllowed( + `${name} language server can only be installed by plugin ${def.installationPluginName}` + ); + } + await installManager.install(def); + } else { + return Boom.notFound(`language server ${name} not found.`); + } + }, + method: 'POST', + }); +} diff --git a/x-pack/plugins/code/server/routes/lsp.ts b/x-pack/plugins/code/server/routes/lsp.ts new file mode 100644 index 000000000000000..ae272d8bdf6496b --- /dev/null +++ b/x-pack/plugins/code/server/routes/lsp.ts @@ -0,0 +1,187 @@ +/* + * 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 Boom from 'boom'; +import hapi from 'hapi'; +import { groupBy, last } from 'lodash'; +import { ResponseError } from 'vscode-jsonrpc'; +import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { Location } from 'vscode-languageserver-types'; +import { ServerNotInitialized, UnknownFileLanguage } from '../../common/lsp_error_codes'; +import { parseLspUrl } from '../../common/uri_util'; +import { GitOperations } from '../git_operations'; +import { Logger } from '../log'; +import { LspService } from '../lsp/lsp_service'; +import { SymbolSearchClient } from '../search'; +import { ServerOptions } from '../server_options'; +import { + expandRanges, + extractSourceContent, + LineMapping, + mergeRanges, +} from '../utils/composite_source_merger'; +import { detectLanguage } from '../utils/detect_language'; +import { EsClientWithRequest } from '../utils/esclient_with_request'; +import { promiseTimeout } from '../utils/timeout'; +import { CodeServerRouter } from '../security'; + +const LANG_SERVER_ERROR = 'language server error'; + +export function lspRoute( + server: CodeServerRouter, + lspService: LspService, + serverOptions: ServerOptions +) { + const log = new Logger(server.server); + server.route({ + path: '/api/code/lsp/textDocument/{method}', + async handler(req, h: hapi.ResponseToolkit) { + if (typeof req.payload === 'object' && req.payload != null) { + const method = req.params.method; + if (method) { + try { + const result = await promiseTimeout( + serverOptions.lsp.requestTimeoutMs, + lspService.sendRequest(`textDocument/${method}`, req.payload, 1000) + ); + return result; + } catch (error) { + if (error instanceof ResponseError) { + // hide some errors; + if (error.code !== UnknownFileLanguage || error.code !== ServerNotInitialized) { + log.debug(error); + } + return h + .response({ error: { code: error.code, msg: LANG_SERVER_ERROR } }) + .type('json') + .code(500); // different code for LS errors and other internal errors. + } else if (error.isBoom) { + return error; + } else { + log.error(error); + return h + .response({ error: { code: error.code || 500, msg: LANG_SERVER_ERROR } }) + .type('json') + .code(500); + } + } + } else { + return h.response('missing `method` in request').code(400); + } + } else { + return h.response('json body required').code(400); // bad request + } + }, + method: 'POST', + }); + + server.route({ + path: '/api/code/lsp/findReferences', + method: 'POST', + async handler(req, h: hapi.ResponseToolkit) { + try { + // @ts-ignore + const { textDocument, position } = req.payload; + const { uri } = textDocument; + const response: ResponseMessage = await promiseTimeout( + serverOptions.lsp.requestTimeoutMs, + lspService.sendRequest( + `textDocument/references`, + { textDocument: { uri }, position }, + 1000 + ) + ); + const hover = await lspService.sendRequest('textDocument/hover', { + textDocument: { uri }, + position, + }); + let title: string; + if (hover.result && hover.result.contents) { + title = Array.isArray(hover.result.contents) + ? hover.result.contents[0].value + : (hover.result.contents as 'string'); + } else { + title = last(uri.toString().split('/')) + `(${position.line}, ${position.character})`; + } + const gitOperations = new GitOperations(serverOptions.repoPath); + const files = []; + const groupedLocations = groupBy(response.result as Location[], 'uri'); + for (const url of Object.keys(groupedLocations)) { + const { repoUri, revision, file } = parseLspUrl(url)!; + const locations: Location[] = groupedLocations[url]; + const lines = locations.map(l => ({ + startLine: l.range.start.line, + endLine: l.range.end.line, + })); + const ranges = expandRanges(lines, 1); + const mergedRanges = mergeRanges(ranges); + const blob = await gitOperations.fileContent(repoUri, file!, revision); + const source = blob + .content() + .toString('utf8') + .split('\n'); + const language = await detectLanguage(file!, blob.content()); + const lineMappings = new LineMapping(); + const code = extractSourceContent(mergedRanges, source, lineMappings).join('\n'); + const lineNumbers = lineMappings.toStringArray(); + const highlights = locations.map(l => { + const { start, end } = l.range; + const startLineNumber = lineMappings.lineNumber(start.line); + const endLineNumber = lineMappings.lineNumber(end.line); + return { + startLineNumber, + startColumn: start.character + 1, + endLineNumber, + endColumn: end.character + 1, + }; + }); + files.push({ + repo: repoUri, + file, + language, + uri: url, + revision, + code, + lineNumbers, + highlights, + }); + } + return { title, files: groupBy(files, 'repo'), uri, position }; + } catch (error) { + log.error(error); + if (error instanceof ResponseError) { + return h + .response({ error: { code: error.code, msg: LANG_SERVER_ERROR } }) + .type('json') + .code(500); // different code for LS errors and other internal errors. + } else if (error.isBoom) { + return error; + } else { + return h + .response({ error: { code: 500, msg: LANG_SERVER_ERROR } }) + .type('json') + .code(500); + } + } + }, + }); +} + +export function symbolByQnameRoute(server: CodeServerRouter, log: Logger) { + server.route({ + path: '/api/code/lsp/symbol/{qname}', + method: 'GET', + async handler(req) { + try { + const symbolSearchClient = new SymbolSearchClient(new EsClientWithRequest(req), log); + const res = await symbolSearchClient.findByQname(req.params.qname); + return res; + } catch (error) { + return Boom.internal(`Search Exception`); + } + }, + }); +} diff --git a/x-pack/plugins/code/server/routes/redirect.ts b/x-pack/plugins/code/server/routes/redirect.ts new file mode 100644 index 000000000000000..17084a98f738ecf --- /dev/null +++ b/x-pack/plugins/code/server/routes/redirect.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 hapi from 'hapi'; +import { Logger } from '../log'; + +export function redirectRoute(server: hapi.Server, redirectUrl: string, log: Logger) { + const proxyHandler = { + proxy: { + passThrough: true, + async mapUri(request: hapi.Request) { + let uri; + uri = `${redirectUrl}${request.path}`; + if (request.url.search) { + uri += request.url.search; + } + log.info(`redirect ${request.path}${request.url.search || ''} to ${uri}`); + return { + uri, + }; + }, + }, + }; + + server.route({ + path: '/api/code/{p*}', + method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + handler: proxyHandler, + }); + + server.route({ + path: '/api/code/lsp/{p*}', + method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + handler: proxyHandler, + }); +} diff --git a/x-pack/plugins/code/server/routes/repository.ts b/x-pack/plugins/code/server/routes/repository.ts new file mode 100644 index 000000000000000..7061775b269e786 --- /dev/null +++ b/x-pack/plugins/code/server/routes/repository.ts @@ -0,0 +1,287 @@ +/* + * 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 Boom from 'boom'; + +import { validateGitUrl } from '../../common/git_url_utils'; +import { RepositoryUtils } from '../../common/repository_utils'; +import { RepositoryConfig, RepositoryUri } from '../../model'; +import { RepositoryIndexInitializer, RepositoryIndexInitializerFactory } from '../indexer'; +import { Logger } from '../log'; +import { CloneWorker, DeleteWorker, IndexWorker } from '../queue'; +import { RepositoryConfigController } from '../repository_config_controller'; +import { RepositoryObjectClient } from '../search'; +import { ServerOptions } from '../server_options'; +import { EsClientWithRequest } from '../utils/esclient_with_request'; +import { CodeServerRouter } from '../security'; + +export function repositoryRoute( + server: CodeServerRouter, + cloneWorker: CloneWorker, + deleteWorker: DeleteWorker, + indexWorker: IndexWorker, + repoIndexInitializerFactory: RepositoryIndexInitializerFactory, + repoConfigController: RepositoryConfigController, + options: ServerOptions +) { + // Clone a git repository + server.route({ + path: '/api/code/repo', + requireAdmin: true, + method: 'POST', + async handler(req, h) { + const repoUrl: string = (req.payload as any).url; + const log = new Logger(req.server); + + // Reject the request if the url is an invalid git url. + try { + validateGitUrl( + repoUrl, + options.security.gitHostWhitelist, + options.security.gitProtocolWhitelist + ); + } catch (error) { + log.error(`Validate git url ${repoUrl} error.`); + log.error(error); + return Boom.badRequest(error); + } + + const repo = RepositoryUtils.buildRepository(repoUrl); + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + + try { + // Check if the repository already exists + await repoObjectClient.getRepository(repo.uri); + const msg = `Repository ${repoUrl} already exists. Skip clone.`; + log.info(msg); + return h.response(msg).code(304); // Not Modified + } catch (error) { + log.info(`Repository ${repoUrl} does not exist. Go ahead with clone.`); + try { + // Create the index for the repository + const initializer = (await repoIndexInitializerFactory.create( + repo.uri, + '' + )) as RepositoryIndexInitializer; + await initializer.init(); + + // Persist to elasticsearch + await repoObjectClient.setRepository(repo.uri, repo); + const randomStr = Math.random() + .toString(36) + .substring(2, 15); + await repoObjectClient.setRepositoryRandomStr(repo.uri, randomStr); + // Kick off clone job + const payload = { + url: repoUrl, + }; + await cloneWorker.enqueueJob(payload, {}); + return repo; + } catch (error2) { + const msg = `Issue repository clone request for ${repoUrl} error`; + log.error(msg); + log.error(error2); + return Boom.badRequest(msg); + } + } + }, + }); + + // Remove a git repository + server.route({ + path: '/api/code/repo/{uri*3}', + requireAdmin: true, + method: 'DELETE', + async handler(req, h) { + const repoUri: string = req.params.uri as string; + const log = new Logger(req.server); + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + try { + // Check if the repository already exists. If not, an error will be thrown. + await repoObjectClient.getRepository(repoUri); + + // Check if the repository delete status already exists. If so, we should ignore this + // request. + try { + await repoObjectClient.getRepositoryDeleteStatus(repoUri); + const msg = `Repository ${repoUri} is already in delete.`; + log.info(msg); + return h.response(msg).code(304); // Not Modified + } catch (error) { + // Do nothing here since this error is expected. + log.info(`Repository ${repoUri} delete status does not exist. Go ahead with delete.`); + } + + const payload = { + uri: repoUri, + }; + await deleteWorker.enqueueJob(payload, {}); + + return {}; + } catch (error) { + const msg = `Issue repository delete request for ${repoUri} error`; + log.error(msg); + log.error(error); + return Boom.notFound(msg); + } + }, + }); + + // Get a git repository + server.route({ + path: '/api/code/repo/{uri*3}', + method: 'GET', + async handler(req) { + const repoUri = req.params.uri as string; + const log = new Logger(req.server); + try { + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + return await repoObjectClient.getRepository(repoUri); + } catch (error) { + const msg = `Get repository ${repoUri} error`; + log.error(msg); + log.error(error); + return Boom.notFound(msg); + } + }, + }); + + server.route({ + path: '/api/code/repo/status/{uri*3}', + method: 'GET', + async handler(req) { + const repoUri = req.params.uri as string; + const log = new Logger(req.server); + try { + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + let gitStatus = null; + try { + gitStatus = await repoObjectClient.getRepositoryGitStatus(repoUri); + } catch (error) { + log.debug(`Get repository git status ${repoUri} error: ${error}`); + } + + let indexStatus = null; + try { + indexStatus = await repoObjectClient.getRepositoryLspIndexStatus(repoUri); + } catch (error) { + log.debug(`Get repository index status ${repoUri} error: ${error}`); + } + + let deleteStatus = null; + try { + deleteStatus = await repoObjectClient.getRepositoryDeleteStatus(repoUri); + } catch (error) { + log.debug(`Get repository delete status ${repoUri} error: ${error}`); + } + return { + gitStatus, + indexStatus, + deleteStatus, + }; + } catch (error) { + const msg = `Get repository status ${repoUri} error`; + log.error(msg); + log.error(error); + return Boom.notFound(msg); + } + }, + }); + + // Get all git repositories + server.route({ + path: '/api/code/repos', + method: 'GET', + async handler(req) { + const log = new Logger(req.server); + try { + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + return await repoObjectClient.getAllRepositories(); + } catch (error) { + const msg = `Get all repositories error`; + log.error(msg); + log.error(error); + return Boom.notFound(msg); + } + }, + }); + + // Issue a repository index task. + // TODO(mengwei): This is just temporary API stub to trigger the index job. Eventually in the near + // future, this route will be removed. The scheduling strategy is still in discussion. + server.route({ + path: '/api/code/repo/index/{uri*3}', + method: 'POST', + requireAdmin: true, + async handler(req) { + const repoUri = req.params.uri as string; + const log = new Logger(req.server); + try { + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + const cloneStatus = await repoObjectClient.getRepositoryGitStatus(repoUri); + + const payload = { + uri: repoUri, + revision: cloneStatus.revision, + }; + await indexWorker.enqueueJob(payload, {}); + return {}; + } catch (error) { + const msg = `Index repository ${repoUri} error`; + log.error(msg); + log.error(error); + return Boom.notFound(msg); + } + }, + }); + + // Update a repo config + server.route({ + path: '/api/code/repo/config/{uri*3}', + method: 'PUT', + requireAdmin: true, + async handler(req, h) { + const config: RepositoryConfig = req.payload as RepositoryConfig; + const repoUri: RepositoryUri = config.uri; + const log = new Logger(req.server); + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + + try { + // Check if the repository exists + await repoObjectClient.getRepository(repoUri); + } catch (error) { + return Boom.badRequest(`Repository not existed for ${repoUri}`); + } + + try { + // Persist to elasticsearch + await repoObjectClient.setRepositoryConfig(repoUri, config); + repoConfigController.resetConfigCache(repoUri); + return {}; + } catch (error) { + const msg = `Update repository config for ${repoUri} error`; + log.error(msg); + log.error(error); + return Boom.badRequest(msg); + } + }, + }); + + // Get repository config + server.route({ + path: '/api/code/repo/config/{uri*3}', + method: 'GET', + async handler(req) { + const repoUri = req.params.uri as string; + try { + const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + return await repoObjectClient.getRepositoryConfig(repoUri); + } catch (error) { + return Boom.notFound(`Repository config ${repoUri} not exist`); + } + }, + }); +} diff --git a/x-pack/plugins/code/server/routes/search.ts b/x-pack/plugins/code/server/routes/search.ts new file mode 100644 index 000000000000000..8b759f502ddf0e1 --- /dev/null +++ b/x-pack/plugins/code/server/routes/search.ts @@ -0,0 +1,180 @@ +/* + * 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 Boom from 'boom'; + +import hapi from 'hapi'; +import { DocumentSearchRequest, RepositorySearchRequest, SymbolSearchRequest } from '../../model'; +import { Logger } from '../log'; +import { DocumentSearchClient, RepositorySearchClient, SymbolSearchClient } from '../search'; +import { EsClientWithRequest } from '../utils/esclient_with_request'; +import { CodeServerRouter } from '../security'; + +export function repositorySearchRoute(server: CodeServerRouter, log: Logger) { + server.route({ + path: '/api/code/search/repo', + method: 'GET', + async handler(req) { + let page = 1; + const { p, q, repoScope } = req.query as hapi.RequestQuery; + if (p) { + page = parseInt(p as string, 10); + } + + let scope: string[] = []; + if (typeof repoScope === 'string') { + scope = repoScope.split(','); + } + + const searchReq: RepositorySearchRequest = { + query: q as string, + page, + repoScope: scope, + }; + try { + const repoSearchClient = new RepositorySearchClient(new EsClientWithRequest(req), log); + const res = await repoSearchClient.search(searchReq); + return res; + } catch (error) { + return Boom.internal(`Search Exception`); + } + }, + }); + + server.route({ + path: '/api/code/suggestions/repo', + method: 'GET', + async handler(req) { + let page = 1; + const { p, q, repoScope } = req.query as hapi.RequestQuery; + if (p) { + page = parseInt(p as string, 10); + } + + let scope: string[] = []; + if (typeof repoScope === 'string') { + scope = repoScope.split(','); + } + + const searchReq: RepositorySearchRequest = { + query: q as string, + page, + repoScope: scope, + }; + try { + const repoSearchClient = new RepositorySearchClient(new EsClientWithRequest(req), log); + const res = await repoSearchClient.suggest(searchReq); + return res; + } catch (error) { + return Boom.internal(`Search Exception`); + } + }, + }); +} + +export function documentSearchRoute(server: CodeServerRouter, log: Logger) { + server.route({ + path: '/api/code/search/doc', + method: 'GET', + async handler(req) { + let page = 1; + const { p, q, langs, repos, repoScope } = req.query as hapi.RequestQuery; + if (p) { + page = parseInt(p as string, 10); + } + + let scope: string[] = []; + if (typeof repoScope === 'string') { + scope = repoScope.split(','); + } + + const searchReq: DocumentSearchRequest = { + query: q as string, + page, + langFilters: langs ? (langs as string).split(',') : [], + repoFilters: repos ? decodeURIComponent(repos as string).split(',') : [], + repoScope: scope, + }; + try { + const docSearchClient = new DocumentSearchClient(new EsClientWithRequest(req), log); + const res = await docSearchClient.search(searchReq); + return res; + } catch (error) { + return Boom.internal(`Search Exception`); + } + }, + }); + + server.route({ + path: '/api/code/suggestions/doc', + method: 'GET', + async handler(req) { + let page = 1; + const { p, q, repoScope } = req.query as hapi.RequestQuery; + if (p) { + page = parseInt(p as string, 10); + } + + let scope: string[] = []; + if (typeof repoScope === 'string') { + scope = repoScope.split(','); + } + + const searchReq: DocumentSearchRequest = { + query: q as string, + page, + repoScope: scope, + }; + try { + const docSearchClient = new DocumentSearchClient(new EsClientWithRequest(req), log); + const res = await docSearchClient.suggest(searchReq); + return res; + } catch (error) { + return Boom.internal(`Search Exception`); + } + }, + }); +} + +export function symbolSearchRoute(server: CodeServerRouter, log: Logger) { + const symbolSearchHandler = async (req: hapi.Request) => { + let page = 1; + const { p, q, repoScope } = req.query as hapi.RequestQuery; + if (p) { + page = parseInt(p as string, 10); + } + + let scope: string[] = []; + if (typeof repoScope === 'string') { + scope = repoScope.split(','); + } + + const searchReq: SymbolSearchRequest = { + query: q as string, + page, + repoScope: scope, + }; + try { + const symbolSearchClient = new SymbolSearchClient(new EsClientWithRequest(req), log); + const res = await symbolSearchClient.suggest(searchReq); + return res; + } catch (error) { + return Boom.internal(`Search Exception`); + } + }; + + // Currently these 2 are the same. + server.route({ + path: '/api/code/suggestions/symbol', + method: 'GET', + handler: symbolSearchHandler, + }); + server.route({ + path: '/api/code/search/symbol', + method: 'GET', + handler: symbolSearchHandler, + }); +} diff --git a/x-pack/plugins/code/server/routes/setup.ts b/x-pack/plugins/code/server/routes/setup.ts new file mode 100644 index 000000000000000..0c75b8ec1d46a08 --- /dev/null +++ b/x-pack/plugins/code/server/routes/setup.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 { ResponseToolkit } from 'hapi'; +import { CodeServerRouter } from '../security'; + +export function setupRoute(server: CodeServerRouter) { + server.route({ + method: 'get', + path: '/api/code/setup', + handler(req, h: ResponseToolkit) { + return h.response('').code(200); + }, + }); +} diff --git a/x-pack/plugins/code/server/routes/workspace.ts b/x-pack/plugins/code/server/routes/workspace.ts new file mode 100644 index 000000000000000..3988d1fc8947eb6 --- /dev/null +++ b/x-pack/plugins/code/server/routes/workspace.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 Boom from 'boom'; +import hapi, { RequestQuery } from 'hapi'; + +import { Logger } from '../log'; +import { WorkspaceCommand } from '../lsp/workspace_command'; +import { WorkspaceHandler } from '../lsp/workspace_handler'; +import { ServerOptions } from '../server_options'; +import { EsClientWithRequest } from '../utils/esclient_with_request'; +import { ServerLoggerFactory } from '../utils/server_logger_factory'; +import { CodeServerRouter } from '../security'; + +export function workspaceRoute(server: CodeServerRouter, serverOptions: ServerOptions) { + server.route({ + path: '/api/code/workspace', + method: 'GET', + async handler() { + return serverOptions.repoConfigs; + }, + }); + + server.route({ + path: '/api/code/workspace/{uri*3}/{revision}', + requireAdmin: true, + method: 'POST', + async handler(req: hapi.Request, reply) { + const repoUri = req.params.uri as string; + const revision = req.params.revision as string; + const repoConfig = serverOptions.repoConfigs[repoUri]; + const force = !!(req.query as RequestQuery).force; + if (repoConfig) { + const log = new Logger(server.server, ['workspace', repoUri]); + const workspaceHandler = new WorkspaceHandler( + serverOptions.repoPath, + serverOptions.workspacePath, + new EsClientWithRequest(req), + new ServerLoggerFactory(server.server) + ); + try { + const { workspaceRepo, workspaceRevision } = await workspaceHandler.openWorkspace( + repoUri, + revision + ); + const workspaceCmd = new WorkspaceCommand( + repoConfig, + workspaceRepo.workdir(), + workspaceRevision, + log + ); + workspaceCmd.runInit(force).then(() => { + return ''; + }); + } catch (e) { + if (e.isBoom) { + return e; + } + } + } else { + return Boom.notFound(`repo config for ${repoUri} not found.`); + } + }, + }); +} diff --git a/x-pack/plugins/code/server/scheduler/abstract_scheduler.ts b/x-pack/plugins/code/server/scheduler/abstract_scheduler.ts new file mode 100644 index 000000000000000..239787eecc64117 --- /dev/null +++ b/x-pack/plugins/code/server/scheduler/abstract_scheduler.ts @@ -0,0 +1,82 @@ +/* + * 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 { Repository } from '../../model'; +import { RepositoryIndexNamePrefix, RepositoryReservedField } from '../indexer/schema'; +import { EsClient } from '../lib/esqueue'; +import { Poller } from '../poller'; + +export abstract class AbstractScheduler { + private poller: Poller; + + constructor( + protected readonly client: EsClient, + pollFrequencyMs: number, + protected readonly onScheduleFinished?: () => void + ) { + this.poller = new Poller({ + functionToPoll: () => { + return this.schedule(); + }, + pollFrequencyInMillis: pollFrequencyMs, + trailing: true, + continuePollingOnError: true, + pollFrequencyErrorMultiplier: 2, + }); + } + + public start() { + this.poller.start(); + } + + public stop() { + this.poller.stop(); + } + + protected async schedule(): Promise { + const res = await this.client.search({ + index: `${RepositoryIndexNamePrefix}*`, + body: { + query: { + exists: { + field: RepositoryReservedField, + }, + }, + }, + from: 0, + size: 10000, + }); + const schedulingPromises = Array.from(res.hits.hits).map((hit: any) => { + const repo: Repository = hit._source[RepositoryReservedField]; + return this.executeSchedulingJob(repo); + }); + await Promise.all(schedulingPromises); + // Execute the callback after each schedule is done. + if (this.onScheduleFinished) { + this.onScheduleFinished(); + } + } + + protected repoNextSchedulingTime(): Date { + const duration = + this.getRepoSchedulingFrequencyMs() / 2 + + ((Math.random() * Number.MAX_SAFE_INTEGER) % this.getRepoSchedulingFrequencyMs()); + const now = new Date().getTime(); + return new Date(now + duration); + } + + protected getRepoSchedulingFrequencyMs() { + // This is an abstract class. Do nothing here. You should override this. + return -1; + } + + protected async executeSchedulingJob(repo: Repository) { + // This is an abstract class. Do nothing here. You should override this. + return new Promise((resolve, _) => { + resolve(); + }); + } +} diff --git a/x-pack/plugins/code/server/scheduler/index.ts b/x-pack/plugins/code/server/scheduler/index.ts new file mode 100644 index 000000000000000..35629a945a03072 --- /dev/null +++ b/x-pack/plugins/code/server/scheduler/index.ts @@ -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 * from './index_scheduler'; +export * from './update_scheduler'; diff --git a/x-pack/plugins/code/server/scheduler/index_scheduler.test.ts b/x-pack/plugins/code/server/scheduler/index_scheduler.test.ts new file mode 100644 index 000000000000000..2334a4f64cb45c7 --- /dev/null +++ b/x-pack/plugins/code/server/scheduler/index_scheduler.test.ts @@ -0,0 +1,353 @@ +/* + * 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 sinon from 'sinon'; + +import { + CloneProgress, + CloneWorkerProgress, + Repository, + WorkerProgress, + WorkerReservedProgress, +} from '../../model'; +import { + RepositoryGitStatusReservedField, + RepositoryLspIndexStatusReservedField, + RepositoryReservedField, +} from '../indexer/schema'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { IndexWorker } from '../queue/index_worker'; +import { ServerOptions } from '../server_options'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { IndexScheduler } from './index_scheduler'; + +const INDEX_FREQUENCY_MS: number = 1000; +const INDEX_REPO_FREQUENCY_MS: number = 8000; +const serverOpts = { + indexFrequencyMs: INDEX_FREQUENCY_MS, + indexRepoFrequencyMs: INDEX_REPO_FREQUENCY_MS, +}; +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esClient = { + get: emptyAsyncFunc, + search: emptyAsyncFunc, + update: emptyAsyncFunc, +}; +const indexWorker = { + enqueueJob: emptyAsyncFunc, +}; + +const createSearchSpy = (nextIndexTimestamp: number): sinon.SinonSpy => { + const repo: Repository = { + uri: 'github.com/elastic/code', + url: 'https://github.com/elastic/code.git', + org: 'elastic', + name: 'code', + revision: 'master', + nextIndexTimestamp: moment() + .add(nextIndexTimestamp, 'ms') + .toDate(), + }; + return sinon.fake.returns( + Promise.resolve({ + hits: { + hits: [ + { + _source: { + [RepositoryReservedField]: repo, + }, + }, + ], + }, + }) + ); +}; + +const createGetStub = ( + gitProgress: number, + lspIndexProgress: number, + lspIndexRevision: string +): sinon.SinonStub => { + const cloneStatus: CloneWorkerProgress = { + uri: 'github.com/elastic/code', + progress: gitProgress, + timestamp: new Date(), + cloneProgress: { + isCloned: true, + } as CloneProgress, + }; + const lspIndexStatus: WorkerProgress = { + uri: 'github.com/elastic/code', + progress: lspIndexProgress, + timestamp: new Date(), + revision: lspIndexRevision, + }; + const stub = sinon.stub(); + stub.onFirstCall().returns( + Promise.resolve({ + _source: { + [RepositoryGitStatusReservedField]: cloneStatus, + }, + }) + ); + stub.onSecondCall().returns( + Promise.resolve({ + _source: { + [RepositoryLspIndexStatusReservedField]: lspIndexStatus, + }, + }) + ); + return stub; +}; + +afterEach(() => { + sinon.restore(); +}); + +test('Next job should not execute when scheduled index time is not current.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the IndexWorker spy. + const enqueueJobSpy = sinon.spy(indexWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(INDEX_FREQUENCY_MS + 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getSpy = sinon.spy(); + esClient.get = getSpy; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories. + expect(searchSpy.calledOnce).toBeTruthy(); + // Expect no update on anything regarding the index task scheduling. + expect(enqueueJobSpy.notCalled).toBeTruthy(); + expect(getSpy.notCalled).toBeTruthy(); + expect(updateSpy.notCalled).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const indexScheduler = new IndexScheduler( + (indexWorker as any) as IndexWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + indexScheduler.start(); + + // Roll the clock to the time when the first scheduled index task + // is executed. + clock.tick(INDEX_FREQUENCY_MS); +}); + +test('Next job should not execute when repo is still in clone.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the IndexWorker spy. + const enqueueJobSpy = sinon.spy(indexWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(INDEX_FREQUENCY_MS - 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getStub = createGetStub(50, WorkerReservedProgress.COMPLETED, 'newrevision'); + esClient.get = getStub; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories and + // the get stub to be called to pull out git status. + expect(searchSpy.calledOnce).toBeTruthy(); + expect(getStub.calledOnce).toBeTruthy(); + // Expect no update on anything regarding the index task scheduling. + expect(enqueueJobSpy.notCalled).toBeTruthy(); + expect(updateSpy.notCalled).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const indexScheduler = new IndexScheduler( + (indexWorker as any) as IndexWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + indexScheduler.start(); + + // Roll the clock to the time when the first scheduled index task + // is executed. + clock.tick(INDEX_FREQUENCY_MS); +}); + +test('Next job should not execute when repo is still in the preivous index job.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the IndexWorker spy. + const enqueueJobSpy = sinon.spy(indexWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(INDEX_FREQUENCY_MS - 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getStub = createGetStub(WorkerReservedProgress.COMPLETED, 50, 'newrevision'); + esClient.get = getStub; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories. + expect(searchSpy.calledOnce).toBeTruthy(); + // Expect the get stub to be called twice to pull out git status and + // lsp index status respectively. + expect(getStub.calledTwice).toBeTruthy(); + // Expect no update on anything regarding the index task scheduling. + expect(enqueueJobSpy.notCalled).toBeTruthy(); + expect(updateSpy.notCalled).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const indexScheduler = new IndexScheduler( + (indexWorker as any) as IndexWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + indexScheduler.start(); + + // Roll the clock to the time when the first scheduled index task + // is executed. + clock.tick(INDEX_FREQUENCY_MS); +}); + +test('Next job should not execute when repo revision did not change.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the IndexWorker spy. + const enqueueJobSpy = sinon.spy(indexWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(INDEX_FREQUENCY_MS - 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getStub = createGetStub( + WorkerReservedProgress.COMPLETED, + WorkerReservedProgress.COMPLETED, + 'master' + ); + esClient.get = getStub; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories. + expect(searchSpy.calledOnce).toBeTruthy(); + // Expect the get stub to be called twice to pull out git status and + // lsp index status respectively. + expect(getStub.calledTwice).toBeTruthy(); + // Expect no update on anything regarding the index task scheduling. + expect(enqueueJobSpy.notCalled).toBeTruthy(); + expect(updateSpy.notCalled).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const indexScheduler = new IndexScheduler( + (indexWorker as any) as IndexWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + indexScheduler.start(); + + // Roll the clock to the time when the first scheduled index task + // is executed. + clock.tick(INDEX_FREQUENCY_MS); +}); + +test('Next job should execute.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the IndexWorker spy. + const enqueueJobSpy = sinon.spy(indexWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(INDEX_FREQUENCY_MS - 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getStub = createGetStub( + WorkerReservedProgress.COMPLETED, + WorkerReservedProgress.COMPLETED, + 'newrevision' + ); + esClient.get = getStub; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories. + expect(searchSpy.calledOnce).toBeTruthy(); + // Expect the get stub to be called twice to pull out git status and + // lsp index status respectively. + expect(getStub.calledTwice).toBeTruthy(); + // Expect the update stub to be called to update next schedule timestamp. + expect(updateSpy.calledOnce).toBeTruthy(); + // Expect the enqueue job stub to be called to issue the index job. + expect(enqueueJobSpy.calledOnce).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const indexScheduler = new IndexScheduler( + (indexWorker as any) as IndexWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + indexScheduler.start(); + + // Roll the clock to the time when the first scheduled index task + // is executed. + clock.tick(INDEX_FREQUENCY_MS); +}); diff --git a/x-pack/plugins/code/server/scheduler/index_scheduler.ts b/x-pack/plugins/code/server/scheduler/index_scheduler.ts new file mode 100644 index 000000000000000..8008f44e6a91e90 --- /dev/null +++ b/x-pack/plugins/code/server/scheduler/index_scheduler.ts @@ -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 { RepositoryUtils } from '../../common/repository_utils'; +import { Repository, WorkerReservedProgress } from '../../model'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { IndexWorker } from '../queue'; +import { RepositoryObjectClient } from '../search'; +import { ServerOptions } from '../server_options'; +import { AbstractScheduler } from './abstract_scheduler'; + +export class IndexScheduler extends AbstractScheduler { + private objectClient: RepositoryObjectClient; + + constructor( + private readonly indexWorker: IndexWorker, + private readonly serverOptions: ServerOptions, + protected readonly client: EsClient, + protected readonly log: Logger, + protected readonly onScheduleFinished?: () => void + ) { + super(client, serverOptions.indexFrequencyMs, onScheduleFinished); + this.objectClient = new RepositoryObjectClient(this.client); + } + + protected getRepoSchedulingFrequencyMs() { + return this.serverOptions.indexRepoFrequencyMs; + } + + protected async executeSchedulingJob(repo: Repository) { + this.log.info(`Schedule index repo request for ${repo.uri}`); + try { + // This repository is too soon to execute the next index job. + if (repo.nextIndexTimestamp && new Date() < new Date(repo.nextIndexTimestamp)) { + this.log.debug(`Repo ${repo.uri} is too soon to execute the next index job.`); + return; + } + const cloneStatus = await this.objectClient.getRepositoryGitStatus(repo.uri); + if ( + !RepositoryUtils.hasFullyCloned(cloneStatus.cloneProgress) || + cloneStatus.progress !== WorkerReservedProgress.COMPLETED + ) { + this.log.info(`Repo ${repo.uri} has not been fully cloned yet or in update. Skip index.`); + return; + } + + const repoIndexStatus = await this.objectClient.getRepositoryLspIndexStatus(repo.uri); + + // Schedule index job only when the indexed revision is different from the current repository + // revision. + this.log.info( + `Current repo revision: ${repo.revision}, indexed revision ${repoIndexStatus.revision}.` + ); + if ( + repoIndexStatus.progress >= 0 && + repoIndexStatus.progress < WorkerReservedProgress.COMPLETED + ) { + this.log.info(`Repo is still in indexing. Skip index for ${repo.uri}`); + } else if ( + repoIndexStatus.progress === WorkerReservedProgress.COMPLETED && + repoIndexStatus.revision === repo.revision + ) { + this.log.info(`Repo does not change since last index. Skip index for ${repo.uri}.`); + } else { + const payload = { + uri: repo.uri, + revision: repo.revision, + }; + + // Update the next repo index timestamp. + const nextRepoIndexTimestamp = this.repoNextSchedulingTime(); + await this.objectClient.updateRepository(repo.uri, { + nextIndexTimestamp: nextRepoIndexTimestamp, + }); + + await this.indexWorker.enqueueJob(payload, {}); + } + } catch (error) { + this.log.error(`Schedule index job for ${repo.uri} error.`); + this.log.error(error); + } + } +} diff --git a/x-pack/plugins/code/server/scheduler/update_scheduler.test.ts b/x-pack/plugins/code/server/scheduler/update_scheduler.test.ts new file mode 100644 index 000000000000000..519811040eb9012 --- /dev/null +++ b/x-pack/plugins/code/server/scheduler/update_scheduler.test.ts @@ -0,0 +1,229 @@ +/* + * 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 sinon from 'sinon'; + +import { + CloneProgress, + CloneWorkerProgress, + Repository, + WorkerReservedProgress, +} from '../../model'; +import { RepositoryGitStatusReservedField, RepositoryReservedField } from '../indexer/schema'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { UpdateWorker } from '../queue/update_worker'; +import { ServerOptions } from '../server_options'; +import { emptyAsyncFunc } from '../test_utils'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { UpdateScheduler } from './update_scheduler'; + +const UPDATE_FREQUENCY_MS: number = 1000; +const UPDATE_REPO_FREQUENCY_MS: number = 8000; +const serverOpts = { + updateFrequencyMs: UPDATE_FREQUENCY_MS, + updateRepoFrequencyMs: UPDATE_REPO_FREQUENCY_MS, +}; +const log: Logger = new ConsoleLoggerFactory().getLogger(['test']); + +const esClient = { + get: emptyAsyncFunc, + search: emptyAsyncFunc, + update: emptyAsyncFunc, +}; +const updateWorker = { + enqueueJob: emptyAsyncFunc, +}; + +const createSearchSpy = (nextUpdateTimestamp: number): sinon.SinonSpy => { + const repo: Repository = { + uri: 'github.com/elastic/code', + url: 'https://github.com/elastic/code.git', + org: 'elastic', + name: 'code', + nextUpdateTimestamp: moment() + .add(nextUpdateTimestamp, 'ms') + .toDate(), + }; + return sinon.fake.returns( + Promise.resolve({ + hits: { + hits: [ + { + _source: { + [RepositoryReservedField]: repo, + }, + }, + ], + }, + }) + ); +}; + +const createGetSpy = (progress: number): sinon.SinonStub => { + const cloneStatus: CloneWorkerProgress = { + uri: 'github.com/elastic/code', + progress, + timestamp: new Date(), + cloneProgress: { + isCloned: true, + } as CloneProgress, + }; + const stub = sinon.stub(); + stub.onFirstCall().throwsException('Failed to get delete status'); + stub.onSecondCall().returns( + Promise.resolve({ + _source: { + [RepositoryGitStatusReservedField]: cloneStatus, + }, + }) + ); + return stub; +}; + +afterEach(() => { + sinon.restore(); +}); + +test('Next job should not execute when scheduled update time is not current.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the UpdateWorker spy. + const enqueueJobSpy = sinon.spy(updateWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(UPDATE_FREQUENCY_MS + 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getSpy = createGetSpy(WorkerReservedProgress.COMPLETED); + esClient.get = getSpy; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories. + expect(searchSpy.calledOnce).toBeTruthy(); + // Expect no update on anything regarding the update task scheduling. + expect(enqueueJobSpy.notCalled).toBeTruthy(); + expect(getSpy.notCalled).toBeTruthy(); + expect(updateSpy.notCalled).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const updateScheduler = new UpdateScheduler( + (updateWorker as any) as UpdateWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + updateScheduler.start(); + + // Roll the clock to the time when the first scheduled update task + // is executed. + clock.tick(UPDATE_FREQUENCY_MS); +}); + +test('Next job should not execute when repo is still in clone.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the UpdateWorker spy. + const enqueueJobSpy = sinon.spy(updateWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(UPDATE_FREQUENCY_MS - 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getSpy = createGetSpy(50); + esClient.get = getSpy; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories and + // the get stub to be called twice to pull out git status and delete + // status. + expect(searchSpy.calledOnce).toBeTruthy(); + expect(getSpy.calledTwice).toBeTruthy(); + // Expect no update on anything regarding the update task scheduling. + expect(enqueueJobSpy.notCalled).toBeTruthy(); + expect(updateSpy.notCalled).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const updateScheduler = new UpdateScheduler( + (updateWorker as any) as UpdateWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + updateScheduler.start(); + + // Roll the clock to the time when the first scheduled update task + // is executed. + clock.tick(UPDATE_FREQUENCY_MS); +}); + +test('Next job should execute.', done => { + const clock = sinon.useFakeTimers(); + + // Setup the UpdateWorker spy. + const enqueueJobSpy = sinon.stub(updateWorker, 'enqueueJob'); + + // Setup the search stub to mock loading all repositories from ES. + const searchSpy = createSearchSpy(UPDATE_FREQUENCY_MS - 1); + esClient.search = searchSpy; + + // Set up the update and get spies of esClient + const getSpy = createGetSpy(WorkerReservedProgress.COMPLETED); + esClient.get = getSpy; + const updateSpy = sinon.spy(); + esClient.update = updateSpy; + + const onScheduleFinished = () => { + try { + // Expect the search stub to be called to pull all repositories. + expect(searchSpy.calledOnce).toBeTruthy(); + // Expect the get stub to be called to pull git status and delete status. + expect(getSpy.calledTwice).toBeTruthy(); + // Expect the update stub to be called to update next schedule timestamp. + expect(updateSpy.calledOnce).toBeTruthy(); + // Expect the enqueue job stub to be called to issue the update job. + expect(enqueueJobSpy.calledOnce).toBeTruthy(); + done(); + } catch (err) { + done.fail(err); + } + }; + + // Start the scheduler. + const updateScheduler = new UpdateScheduler( + (updateWorker as any) as UpdateWorker, + serverOpts as ServerOptions, + esClient as EsClient, + log, + onScheduleFinished + ); + updateScheduler.start(); + + // Roll the clock to the time when the first scheduled update task + // is executed. + clock.tick(UPDATE_FREQUENCY_MS); +}); diff --git a/x-pack/plugins/code/server/scheduler/update_scheduler.ts b/x-pack/plugins/code/server/scheduler/update_scheduler.ts new file mode 100644 index 000000000000000..1ff0f29095c6ac9 --- /dev/null +++ b/x-pack/plugins/code/server/scheduler/update_scheduler.ts @@ -0,0 +1,81 @@ +/* + * 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 { Repository, WorkerReservedProgress } from '../../model'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { UpdateWorker } from '../queue'; +import { RepositoryObjectClient } from '../search'; +import { ServerOptions } from '../server_options'; +import { AbstractScheduler } from './abstract_scheduler'; + +export class UpdateScheduler extends AbstractScheduler { + private objectClient: RepositoryObjectClient; + + constructor( + private readonly updateWorker: UpdateWorker, + private readonly serverOptions: ServerOptions, + protected readonly client: EsClient, + protected readonly log: Logger, + protected readonly onScheduleFinished?: () => void + ) { + super(client, serverOptions.updateFrequencyMs, onScheduleFinished); + this.objectClient = new RepositoryObjectClient(this.client); + } + + protected getRepoSchedulingFrequencyMs() { + return this.serverOptions.updateRepoFrequencyMs; + } + + // TODO: Currently the schduling algorithm the most naive one, which go through + // all repositories and execute update. Later we can repeat the one we used + // before for task throttling. + protected async executeSchedulingJob(repo: Repository) { + this.log.debug(`Try to schedule update repo request for ${repo.uri}`); + try { + // This repository is too soon to execute the next update job. + if (repo.nextUpdateTimestamp && new Date() < new Date(repo.nextUpdateTimestamp)) { + this.log.debug(`Repo ${repo.uri} is too soon to execute the next update job.`); + return; + } + this.log.info(`Start to schedule update repo request for ${repo.uri}`); + + let inDelete = false; + try { + await this.objectClient.getRepositoryDeleteStatus(repo.uri); + inDelete = true; + } catch (error) { + inDelete = false; + } + + const cloneStatus = await this.objectClient.getRepositoryGitStatus(repo.uri); + // Schedule update job only when the repo has been fully cloned already and not in delete + if ( + !inDelete && + cloneStatus.cloneProgress && + cloneStatus.cloneProgress.isCloned && + cloneStatus.progress === WorkerReservedProgress.COMPLETED + ) { + const payload = repo; + + // Update the next repo update timestamp. + const nextRepoUpdateTimestamp = this.repoNextSchedulingTime(); + await this.objectClient.updateRepository(repo.uri, { + nextUpdateTimestamp: nextRepoUpdateTimestamp, + }); + + await this.updateWorker.enqueueJob(payload, {}); + } else { + this.log.info( + `Repo ${repo.uri} has not been fully cloned yet or in update/delete. Skip update.` + ); + } + } catch (error) { + this.log.error(`Schedule update for ${repo.uri} error.`); + this.log.error(error); + } + } +} diff --git a/x-pack/plugins/code/server/search/abstract_search_client.ts b/x-pack/plugins/code/server/search/abstract_search_client.ts new file mode 100644 index 000000000000000..e0390d660fb3926 --- /dev/null +++ b/x-pack/plugins/code/server/search/abstract_search_client.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 { SearchRequest, SearchResult } from '../../model'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { SearchClient } from './search_client'; + +export abstract class AbstractSearchClient implements SearchClient { + protected RESULTS_PER_PAGE = 20; + + constructor(protected readonly client: EsClient, protected readonly log: Logger) {} + + // For the full search request. + public async search(req: SearchRequest): Promise { + // This is the abstract implementation, you should override this function. + return new Promise((resolve, reject) => { + resolve(); + }); + } + + // For the typeahead suggestions request. + public async suggest(req: SearchRequest): Promise { + // This is the abstract implementation, you should override this function. + // By default, return the same result as search function above. + return this.search(req); + } + + public getResultsPerPage(req: SearchRequest): number { + let resultsPerPage = this.RESULTS_PER_PAGE; + if (req.resultsPerPage !== undefined) { + resultsPerPage = req.resultsPerPage; + } + return resultsPerPage; + } +} diff --git a/x-pack/plugins/code/server/search/document_search_client.test.ts b/x-pack/plugins/code/server/search/document_search_client.test.ts new file mode 100644 index 000000000000000..2f3d80a53ddc8cf --- /dev/null +++ b/x-pack/plugins/code/server/search/document_search_client.test.ts @@ -0,0 +1,230 @@ +/* + * 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 sinon from 'sinon'; + +import { AnyObject, EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { DocumentSearchClient } from './document_search_client'; + +let docSearchClient: DocumentSearchClient; +let esClient; + +// Setup the entire RepositorySearchClient. +function initSearchClient() { + const log: Logger = (sinon.stub() as any) as Logger; + esClient = initEsClient(); + + docSearchClient = new DocumentSearchClient(esClient, log); +} + +const mockSearchResults = [ + // 1. The first response is a valid DocumentSearchResult with 2 docs + { + took: 1, + hits: { + total: { + value: 2, + }, + hits: [ + // File content matching + { + _source: { + repoUri: 'github.com/Microsoft/TypeScript-Node-Starter', + path: 'src/types/express-flash.d.ts', + content: + "\n/// \n\n// Add RequestValidation Interface on to Express's Request Interface.\ndeclare namespace Express {\n interface Request extends Flash {}\n}\n\ninterface Flash {\n flash(type: string, message: any): void;\n}\n\ndeclare module 'express-flash';\n\n", + language: 'typescript', + qnames: ['express-flash', 'Express', 'Request', 'Flash', 'flash'], + }, + highlight: { + content: [ + 'declare namespace Express {\n interface Request extends Flash {}\n}\n\ninterface Flash {\n flash(type: _@_string_@_', + ], + }, + }, + // File path matching + { + _source: { + repoUri: 'github.com/Microsoft/TypeScript-Node-Starter', + path: 'src/types/string.d.ts', + content: + 'no query in content;\nno query in content;\nno query in content;\nno query in content;\nno query in content;\n', + language: 'typescript', + qnames: ['express-flash'], + }, + highlight: { + content: [], + }, + }, + ], + }, + aggregations: { + repoUri: { + buckets: [ + { + 'github.com/Microsoft/TypeScript-Node-Starter': 2, + }, + ], + }, + language: { + buckets: [ + { + typescript: 2, + }, + ], + }, + }, + }, + // 2. The second response is a valid DocumentSearchResult with 0 doc + { + took: 1, + hits: { + total: { + value: 0, + }, + hits: [], + }, + aggregations: { + repoUri: { + buckets: [], + }, + language: { + buckets: [], + }, + }, + }, +]; + +// Setup the mock EsClient. +function initEsClient(): EsClient { + esClient = { + search: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, + }; + const searchStub = sinon.stub(esClient, 'search'); + + // Binding the mock search results to the stub. + mockSearchResults.forEach((result, index) => { + searchStub.onCall(index).returns(Promise.resolve(result)); + }); + + return (esClient as any) as EsClient; +} + +beforeEach(() => { + initSearchClient(); +}); + +test('Document search', async () => { + // 1. The first response should have 1 result. + const responseWithResult = await docSearchClient.search({ query: 'string', page: 1 }); + expect(responseWithResult).toEqual( + expect.objectContaining({ + total: 2, + totalPage: 1, + page: 1, + query: 'string', + results: [ + { + uri: 'github.com/Microsoft/TypeScript-Node-Starter', + filePath: 'src/types/express-flash.d.ts', + compositeContent: { + // Content is shorted + content: '\n\ninterface Flash {\n flash(type: string, message: any): void;\n}\n\n', + // Line mapping data is populated + lineMapping: ['..', '8', '9', '10', '11', '12', '..'], + // Highlight ranges are calculated + ranges: [ + { + endColumn: 23, + endLineNumber: 4, + startColumn: 17, + startLineNumber: 4, + }, + ], + }, + language: 'typescript', + hits: 1, + }, + { + uri: 'github.com/Microsoft/TypeScript-Node-Starter', + filePath: 'src/types/string.d.ts', + compositeContent: { + // Content is shorted + content: 'no query in content;\nno query in content;\nno query in content;\n', + // Line mapping data is populated + lineMapping: ['1', '2', '3', '..'], + // Highlight ranges are calculated + ranges: [], + }, + language: 'typescript', + hits: 0, + }, + ], + repoAggregations: [ + { + 'github.com/Microsoft/TypeScript-Node-Starter': 2, + }, + ], + langAggregations: [ + { + typescript: 2, + }, + ], + }) + ); + + // 2. The first response should have 0 results. + const responseWithEmptyResult = await docSearchClient.search({ query: 'string', page: 1 }); + expect(responseWithEmptyResult.results!.length).toEqual(0); + expect(responseWithEmptyResult.total).toEqual(0); +}); + +test('Document suggest', async () => { + // 1. The first response should have 1 result. + const responseWithResult = await docSearchClient.suggest({ query: 'string', page: 1 }); + expect(responseWithResult).toEqual( + expect.objectContaining({ + total: 2, + totalPage: 1, + page: 1, + query: 'string', + results: [ + { + uri: 'github.com/Microsoft/TypeScript-Node-Starter', + filePath: 'src/types/express-flash.d.ts', + // compositeContent field is intended to leave empty. + compositeContent: { + content: '', + lineMapping: [], + ranges: [], + }, + language: 'typescript', + hits: 0, + }, + { + uri: 'github.com/Microsoft/TypeScript-Node-Starter', + filePath: 'src/types/string.d.ts', + // compositeContent field is intended to leave empty. + compositeContent: { + content: '', + lineMapping: [], + ranges: [], + }, + language: 'typescript', + hits: 0, + }, + ], + }) + ); + + // 2. The second response should have 0 result. + const responseWithEmptyResult = await docSearchClient.suggest({ query: 'string', page: 1 }); + expect(responseWithEmptyResult.results!.length).toEqual(0); + expect(responseWithEmptyResult.total).toEqual(0); +}); diff --git a/x-pack/plugins/code/server/search/document_search_client.ts b/x-pack/plugins/code/server/search/document_search_client.ts new file mode 100644 index 000000000000000..d6193e2a9f624a8 --- /dev/null +++ b/x-pack/plugins/code/server/search/document_search_client.ts @@ -0,0 +1,390 @@ +/* + * 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 { IRange } from 'monaco-editor'; +import { LineMapper } from '../../common/line_mapper'; +import { + Document, + DocumentSearchRequest, + DocumentSearchResult, + SearchResultItem, + SourceHit, + SourceRange, +} from '../../model'; +import { DocumentIndexNamePrefix, DocumentSearchIndexWithScope } from '../indexer/schema'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { + expandRanges, + extractSourceContent, + LineMapping, + LineRange, + mergeRanges, +} from '../utils/composite_source_merger'; +import { AbstractSearchClient } from './abstract_search_client'; + +const HIT_MERGE_LINE_INTERVAL = 2; // Inclusive +const MAX_HIT_NUMBER = 5; + +export class DocumentSearchClient extends AbstractSearchClient { + private HIGHLIGHT_TAG = '_@_'; + private LINE_SEPARATOR = '\n'; + + constructor(protected readonly client: EsClient, protected readonly log: Logger) { + super(client, log); + } + + public async search(req: DocumentSearchRequest): Promise { + const resultsPerPage = this.getResultsPerPage(req); + const from = (req.page - 1) * resultsPerPage; + const size = resultsPerPage; + + // The query to search qname field. + const qnameQuery = { + constant_score: { + filter: { + match: { + qnames: { + query: req.query, + operator: 'OR', + prefix_length: 0, + max_expansions: 50, + fuzzy_transpositions: true, + lenient: false, + zero_terms_query: 'NONE', + boost: 1.0, + }, + }, + }, + boost: 1.0, + }, + }; + + // The query to search content and path filter. + const contentAndPathQuery = { + simple_query_string: { + query: req.query, + fields: ['content^1.0', 'path^1.0'], + default_operator: 'or', + lenient: false, + analyze_wildcard: false, + boost: 1.0, + }, + }; + + // Post filters for repository + let repositoryPostFilters: object[] = []; + if (req.repoFilters) { + repositoryPostFilters = req.repoFilters.map((repoUri: string) => { + return { + term: { + repoUri, + }, + }; + }); + } + + // Post filters for language + let languagePostFilters: object[] = []; + if (req.langFilters) { + languagePostFilters = req.langFilters.map((lang: string) => { + return { + term: { + language: lang, + }, + }; + }); + } + + const index = req.repoScope + ? DocumentSearchIndexWithScope(req.repoScope) + : `${DocumentIndexNamePrefix}*`; + + const rawRes = await this.client.search({ + index, + body: { + from, + size, + query: { + bool: { + should: [qnameQuery, contentAndPathQuery], + disable_coord: false, + adjust_pure_negative: true, + boost: 1.0, + }, + }, + post_filter: { + bool: { + must: [ + { + bool: { + should: repositoryPostFilters, + disable_coord: false, + adjust_pure_negative: true, + boost: 1.0, + }, + }, + { + bool: { + should: languagePostFilters, + disable_coord: false, + adjust_pure_negative: true, + boost: 1.0, + }, + }, + ], + disable_coord: false, + adjust_pure_negative: true, + boost: 1.0, + }, + }, + aggregations: { + repoUri: { + terms: { + field: 'repoUri', + size: 10, + min_doc_count: 1, + shard_min_doc_count: 0, + show_term_doc_count_error: false, + order: [ + { + _count: 'desc', + }, + { + _key: 'asc', + }, + ], + }, + }, + language: { + terms: { + field: 'language', + size: 10, + min_doc_count: 1, + shard_min_doc_count: 0, + show_term_doc_count_error: false, + order: [ + { + _count: 'desc', + }, + { + _key: 'asc', + }, + ], + }, + }, + }, + highlight: { + // TODO: we might need to improve the highlighting separator. + pre_tags: [this.HIGHLIGHT_TAG], + post_tags: [this.HIGHLIGHT_TAG], + fields: { + content: {}, + path: {}, + }, + }, + }, + }); + + const hits: any[] = rawRes.hits.hits; + const aggregations = rawRes.aggregations; + const results: SearchResultItem[] = hits.map(hit => { + const doc: Document = hit._source; + const { repoUri, path, language } = doc; + + const highlight = hit.highlight; + // Similar to https://github.com/lambdalab/lambdalab/blob/master/services/liaceservice/src/main/scala/com/lambdalab/liaceservice/LiaceServiceImpl.scala#L147 + // Might need refactoring. + const highlightContent: string[] = highlight.content; + let termContent: string[] = []; + if (highlightContent) { + highlightContent.forEach((c: string) => { + termContent = termContent.concat(this.extractKeywords(c)); + }); + } + const hitsContent = this.termsToHits(doc.content, termContent); + const sourceContent = this.getSourceContent(hitsContent, doc); + const item: SearchResultItem = { + uri: repoUri, + filePath: path, + language: language!, + hits: hitsContent.length, + compositeContent: sourceContent, + }; + return item; + }); + const total = rawRes.hits.total.value; + return { + query: req.query, + from, + page: req.page, + totalPage: Math.ceil(total / resultsPerPage), + results, + repoAggregations: aggregations.repoUri.buckets, + langAggregations: aggregations.language.buckets, + took: rawRes.took, + total, + }; + } + + public async suggest(req: DocumentSearchRequest): Promise { + const resultsPerPage = this.getResultsPerPage(req); + const from = (req.page - 1) * resultsPerPage; + const size = resultsPerPage; + + const index = req.repoScope + ? DocumentSearchIndexWithScope(req.repoScope) + : `${DocumentIndexNamePrefix}*`; + + const rawRes = await this.client.search({ + index, + body: { + from, + size, + query: { + bool: { + should: [ + { + prefix: { + 'path.hierarchy': { + value: req.query, + boost: 1.0, + }, + }, + }, + { + term: { + 'path.hierarchy': { + value: req.query, + boost: 10.0, + }, + }, + }, + ], + disable_coord: false, + adjust_pure_negative: true, + boost: 1.0, + }, + }, + }, + }); + + const hits: any[] = rawRes.hits.hits; + const results: SearchResultItem[] = hits.map(hit => { + const doc: Document = hit._source; + const { repoUri, path, language } = doc; + + const item: SearchResultItem = { + uri: repoUri, + filePath: path, + language: language!, + hits: 0, + compositeContent: { + content: '', + lineMapping: [], + ranges: [], + }, + }; + return item; + }); + const total = rawRes.hits.total.value; + return { + query: req.query, + from, + page: req.page, + totalPage: Math.ceil(total / resultsPerPage), + results, + took: rawRes.took, + total, + }; + } + + private getSourceContent(hitsContent: SourceHit[], doc: Document) { + const docInLines = doc.content.split(this.LINE_SEPARATOR); + let slicedRanges: LineRange[] = []; + if (hitsContent.length === 0) { + // Always add a placeholder range of the first line so that for filepath + // matching search result, we will render some file content. + slicedRanges = [ + { + startLine: 0, + endLine: 0, + }, + ]; + } else { + const slicedHighlights = hitsContent.slice(0, MAX_HIT_NUMBER); + slicedRanges = slicedHighlights.map(hit => ({ + startLine: hit.range.startLoc.line, + endLine: hit.range.endLoc.line, + })); + } + + const expandedRanges = expandRanges(slicedRanges, HIT_MERGE_LINE_INTERVAL); + const mergedRanges = mergeRanges(expandedRanges); + const lineMapping = new LineMapping(); + const result = extractSourceContent(mergedRanges, docInLines, lineMapping); + const ranges: IRange[] = hitsContent + .filter(hit => lineMapping.hasLine(hit.range.startLoc.line)) + .map(hit => ({ + startColumn: hit.range.startLoc.column + 1, + startLineNumber: lineMapping.lineNumber(hit.range.startLoc.line), + endColumn: hit.range.endLoc.column + 1, + endLineNumber: lineMapping.lineNumber(hit.range.endLoc.line), + })); + return { + content: result.join(this.LINE_SEPARATOR), + lineMapping: lineMapping.toStringArray(), + ranges, + }; + } + + private termsToHits(source: string, terms: string[]): SourceHit[] { + if (terms.length === 0) { + return []; + } + + const lineMapper = new LineMapper(source); + const regex = new RegExp(`(${terms.join('|')})`, 'g'); + let match; + const hits: SourceHit[] = []; + do { + match = regex.exec(source); + if (match) { + const begin = match.index; + const end = regex.lastIndex; + const startLoc = lineMapper.getLocation(begin); + const endLoc = lineMapper.getLocation(end); + const range: SourceRange = { + startLoc, + endLoc, + }; + const hit: SourceHit = { + range, + score: 0.0, + term: match[1], + }; + hits.push(hit); + } + } while (match); + return hits; + } + + private extractKeywords(text: string | null): string[] { + if (!text) { + return []; + } else { + const keywordRegex = new RegExp(`${this.HIGHLIGHT_TAG}(\\w*)${this.HIGHLIGHT_TAG}`, 'g'); + const keywords = text.match(keywordRegex); + if (keywords) { + return keywords.map((k: string) => { + return k.replace(new RegExp(this.HIGHLIGHT_TAG, 'g'), ''); + }); + } else { + return []; + } + } + } +} diff --git a/x-pack/plugins/code/server/search/index.ts b/x-pack/plugins/code/server/search/index.ts new file mode 100644 index 000000000000000..91d55a93c0f56a0 --- /dev/null +++ b/x-pack/plugins/code/server/search/index.ts @@ -0,0 +1,10 @@ +/* + * 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 './document_search_client'; +export * from './repository_search_client'; +export * from './symbol_search_client'; +export * from './repository_object_client'; diff --git a/x-pack/plugins/code/server/search/repository_object_client.test.ts b/x-pack/plugins/code/server/search/repository_object_client.test.ts new file mode 100644 index 000000000000000..dd4b2f4bfbbecb8 --- /dev/null +++ b/x-pack/plugins/code/server/search/repository_object_client.test.ts @@ -0,0 +1,318 @@ +/* + * 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 sinon from 'sinon'; + +import { + CloneWorkerProgress, + Repository, + WorkerProgress, + WorkerReservedProgress, +} from '../../model'; +import { + RepositoryDeleteStatusReservedField, + RepositoryGitStatusReservedField, + RepositoryIndexName, + RepositoryIndexNamePrefix, + RepositoryLspIndexStatusReservedField, + RepositoryReservedField, +} from '../indexer/schema'; +import { AnyObject, EsClient } from '../lib/esqueue'; +import { RepositoryObjectClient } from './repository_object_client'; + +const esClient = { + get: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, + search: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, + update: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, + delete: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, + index: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, +}; +const repoObjectClient = new RepositoryObjectClient((esClient as any) as EsClient); + +afterEach(() => { + sinon.restore(); +}); + +test('CRUD of Repository', async () => { + const repoUri = 'github.com/elastic/code'; + + // Create + const indexSpy = sinon.spy(esClient, 'index'); + const cObj: Repository = { + uri: repoUri, + url: 'https://github.com/elastic/code.git', + org: 'elastic', + name: 'code', + }; + await repoObjectClient.setRepository(repoUri, cObj); + expect(indexSpy.calledOnce).toBeTruthy(); + expect(indexSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryReservedField, + body: JSON.stringify({ + [RepositoryReservedField]: cObj, + }), + }) + ); + + // Read + const getFake = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryReservedField]: cObj, + }, + }) + ); + esClient.get = getFake; + await repoObjectClient.getRepository(repoUri); + expect(getFake.calledOnce).toBeTruthy(); + expect(getFake.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryReservedField, + }) + ); + + // Update + const updateSpy = sinon.spy(esClient, 'update'); + const uObj = { + url: 'https://github.com/elastic/codesearch.git', + }; + await repoObjectClient.updateRepository(repoUri, uObj); + expect(updateSpy.calledOnce).toBeTruthy(); + expect(updateSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryReservedField, + body: JSON.stringify({ + doc: { + [RepositoryReservedField]: uObj, + }, + }), + }) + ); +}); + +test('Get All Repositories', async () => { + const cObj: Repository = { + uri: 'github.com/elastic/code', + url: 'https://github.com/elastic/code.git', + org: 'elastic', + name: 'code', + }; + const searchFake = sinon.fake.returns( + Promise.resolve({ + hits: { + hits: [ + { + _source: { + [RepositoryReservedField]: cObj, + }, + }, + ], + }, + }) + ); + esClient.search = searchFake; + await repoObjectClient.getAllRepositories(); + expect(searchFake.calledOnce).toBeTruthy(); + expect(searchFake.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: `${RepositoryIndexNamePrefix}*`, + }) + ); +}); + +test('CRUD of Repository Git Status', async () => { + const repoUri = 'github.com/elastic/code'; + + // Create + const indexSpy = sinon.spy(esClient, 'index'); + const cObj: CloneWorkerProgress = { + uri: repoUri, + progress: WorkerReservedProgress.COMPLETED, + timestamp: new Date(), + }; + await repoObjectClient.setRepositoryGitStatus(repoUri, cObj); + expect(indexSpy.calledOnce).toBeTruthy(); + expect(indexSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryGitStatusReservedField, + body: JSON.stringify({ + [RepositoryGitStatusReservedField]: cObj, + }), + }) + ); + + // Read + const getFake = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryGitStatusReservedField]: cObj, + }, + }) + ); + esClient.get = getFake; + await repoObjectClient.getRepositoryGitStatus(repoUri); + expect(getFake.calledOnce).toBeTruthy(); + expect(getFake.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryGitStatusReservedField, + }) + ); + + // Update + const updateSpy = sinon.spy(esClient, 'update'); + const uObj = { + progress: 50, + }; + await repoObjectClient.updateRepositoryGitStatus(repoUri, uObj); + expect(updateSpy.calledOnce).toBeTruthy(); + expect(updateSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryGitStatusReservedField, + body: JSON.stringify({ + doc: { + [RepositoryGitStatusReservedField]: uObj, + }, + }), + }) + ); +}); + +test('CRUD of Repository LSP Index Status', async () => { + const repoUri = 'github.com/elastic/code'; + + // Create + const indexSpy = sinon.spy(esClient, 'index'); + const cObj: WorkerProgress = { + uri: repoUri, + progress: WorkerReservedProgress.COMPLETED, + timestamp: new Date(), + }; + await repoObjectClient.setRepositoryLspIndexStatus(repoUri, cObj); + expect(indexSpy.calledOnce).toBeTruthy(); + expect(indexSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryLspIndexStatusReservedField, + body: JSON.stringify({ + [RepositoryLspIndexStatusReservedField]: cObj, + }), + }) + ); + + // Read + const getFake = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryLspIndexStatusReservedField]: cObj, + }, + }) + ); + esClient.get = getFake; + await repoObjectClient.getRepositoryLspIndexStatus(repoUri); + expect(getFake.calledOnce).toBeTruthy(); + expect(getFake.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryLspIndexStatusReservedField, + }) + ); + + // Update + const updateSpy = sinon.spy(esClient, 'update'); + const uObj = { + progress: 50, + }; + await repoObjectClient.updateRepositoryLspIndexStatus(repoUri, uObj); + expect(updateSpy.calledOnce).toBeTruthy(); + expect(updateSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryLspIndexStatusReservedField, + body: JSON.stringify({ + doc: { + [RepositoryLspIndexStatusReservedField]: uObj, + }, + }), + }) + ); +}); + +test('CRUD of Repository Delete Status', async () => { + const repoUri = 'github.com/elastic/code'; + + // Create + const indexSpy = sinon.spy(esClient, 'index'); + const cObj: CloneWorkerProgress = { + uri: repoUri, + progress: WorkerReservedProgress.COMPLETED, + timestamp: new Date(), + }; + await repoObjectClient.setRepositoryDeleteStatus(repoUri, cObj); + expect(indexSpy.calledOnce).toBeTruthy(); + expect(indexSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryDeleteStatusReservedField, + body: JSON.stringify({ + [RepositoryDeleteStatusReservedField]: cObj, + }), + }) + ); + + // Read + const getFake = sinon.fake.returns( + Promise.resolve({ + _source: { + [RepositoryDeleteStatusReservedField]: cObj, + }, + }) + ); + esClient.get = getFake; + await repoObjectClient.getRepositoryDeleteStatus(repoUri); + expect(getFake.calledOnce).toBeTruthy(); + expect(getFake.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryDeleteStatusReservedField, + }) + ); + + // Update + const updateSpy = sinon.spy(esClient, 'update'); + const uObj = { + progress: 50, + }; + await repoObjectClient.updateRepositoryDeleteStatus(repoUri, uObj); + expect(updateSpy.calledOnce).toBeTruthy(); + expect(updateSpy.getCall(0).args[0]).toEqual( + expect.objectContaining({ + index: RepositoryIndexName(repoUri), + id: RepositoryDeleteStatusReservedField, + body: JSON.stringify({ + doc: { + [RepositoryDeleteStatusReservedField]: uObj, + }, + }), + }) + ); +}); diff --git a/x-pack/plugins/code/server/search/repository_object_client.ts b/x-pack/plugins/code/server/search/repository_object_client.ts new file mode 100644 index 000000000000000..f62e7c64ad5f9ac --- /dev/null +++ b/x-pack/plugins/code/server/search/repository_object_client.ts @@ -0,0 +1,168 @@ +/* + * 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 { + CloneWorkerProgress, + Repository, + RepositoryConfig, + RepositoryUri, + WorkerProgress, +} from '../../model'; +import { + RepositoryConfigReservedField, + RepositoryDeleteStatusReservedField, + RepositoryGitStatusReservedField, + RepositoryIndexName, + RepositoryIndexNamePrefix, + RepositoryLspIndexStatusReservedField, + RepositoryRandomPathReservedField, + RepositoryReservedField, +} from '../indexer/schema'; +import { EsClient } from '../lib/esqueue'; + +/* + * This RepositoryObjectClient is dedicated to manipulate resository related objects + * stored in ES. + */ +export class RepositoryObjectClient { + constructor(protected readonly esClient: EsClient) {} + + public async getRepositoryGitStatus(repoUri: RepositoryUri): Promise { + return await this.getRepositoryObject(repoUri, RepositoryGitStatusReservedField); + } + + public async getRepositoryLspIndexStatus(repoUri: RepositoryUri): Promise { + return await this.getRepositoryObject(repoUri, RepositoryLspIndexStatusReservedField); + } + + public async getRepositoryDeleteStatus(repoUri: RepositoryUri): Promise { + return await this.getRepositoryObject(repoUri, RepositoryDeleteStatusReservedField); + } + + public async getRepositoryConfig(repoUri: RepositoryUri): Promise { + return await this.getRepositoryObject(repoUri, RepositoryConfigReservedField); + } + + public async getRepository(repoUri: RepositoryUri): Promise { + return await this.getRepositoryObject(repoUri, RepositoryReservedField); + } + + public async getRepositoryRandomStr(repoUri: RepositoryUri): Promise { + return await this.getRepositoryObject(repoUri, RepositoryRandomPathReservedField); + } + + public async getAllRepositories(): Promise { + const res = await this.esClient.search({ + index: `${RepositoryIndexNamePrefix}*`, + body: { + query: { + exists: { + field: RepositoryReservedField, + }, + }, + }, + from: 0, + size: 10000, + }); + const hits: any[] = res.hits.hits; + const repos: Repository[] = hits.map(hit => { + const repo: Repository = hit._source[RepositoryReservedField]; + return repo; + }); + return repos; + } + + public async setRepositoryGitStatus(repoUri: RepositoryUri, gitStatus: CloneWorkerProgress) { + return await this.setRepositoryObject(repoUri, RepositoryGitStatusReservedField, gitStatus); + } + + public async setRepositoryLspIndexStatus(repoUri: RepositoryUri, indexStatus: WorkerProgress) { + return await this.setRepositoryObject( + repoUri, + RepositoryLspIndexStatusReservedField, + indexStatus + ); + } + + public async setRepositoryDeleteStatus(repoUri: RepositoryUri, deleteStatus: WorkerProgress) { + return await this.setRepositoryObject( + repoUri, + RepositoryDeleteStatusReservedField, + deleteStatus + ); + } + + public async setRepositoryConfig(repoUri: RepositoryUri, config: RepositoryConfig) { + return await this.setRepositoryObject(repoUri, RepositoryConfigReservedField, config); + } + + public async setRepositoryRandomStr(repoUri: RepositoryUri, randomStr: string) { + return await this.setRepositoryObject(repoUri, RepositoryRandomPathReservedField, randomStr); + } + + public async setRepository(repoUri: RepositoryUri, repo: Repository) { + return await this.setRepositoryObject(repoUri, RepositoryReservedField, repo); + } + + public async updateRepositoryGitStatus(repoUri: RepositoryUri, obj: any) { + return await this.updateRepositoryObject(repoUri, RepositoryGitStatusReservedField, obj); + } + + public async updateRepositoryLspIndexStatus(repoUri: RepositoryUri, obj: any) { + return await this.updateRepositoryObject(repoUri, RepositoryLspIndexStatusReservedField, obj); + } + + public async updateRepositoryDeleteStatus(repoUri: RepositoryUri, obj: any) { + return await this.updateRepositoryObject(repoUri, RepositoryDeleteStatusReservedField, obj); + } + + public async updateRepository(repoUri: RepositoryUri, obj: any) { + return await this.updateRepositoryObject(repoUri, RepositoryReservedField, obj); + } + + private async getRepositoryObject( + repoUri: RepositoryUri, + reservedFieldName: string + ): Promise { + const res = await this.esClient.get({ + index: RepositoryIndexName(repoUri), + id: this.getRepositoryObjectId(reservedFieldName), + }); + return res._source[reservedFieldName]; + } + + private async setRepositoryObject(repoUri: RepositoryUri, reservedFieldName: string, obj: any) { + return await this.esClient.index({ + index: RepositoryIndexName(repoUri), + id: this.getRepositoryObjectId(reservedFieldName), + refresh: 'true', + body: JSON.stringify({ + [reservedFieldName]: obj, + }), + }); + } + + private async updateRepositoryObject( + repoUri: RepositoryUri, + reservedFieldName: string, + obj: any + ) { + return await this.esClient.update({ + index: RepositoryIndexName(repoUri), + id: this.getRepositoryObjectId(reservedFieldName), + refresh: 'true', + body: JSON.stringify({ + doc: { + [reservedFieldName]: obj, + }, + }), + }); + } + + private getRepositoryObjectId(reservedFieldName: string): string { + return reservedFieldName; + } +} diff --git a/x-pack/plugins/code/server/search/repository_search_client.test.ts b/x-pack/plugins/code/server/search/repository_search_client.test.ts new file mode 100644 index 000000000000000..1198ac7fb139baa --- /dev/null +++ b/x-pack/plugins/code/server/search/repository_search_client.test.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. + */ + +import sinon from 'sinon'; + +import { RepositoryReservedField } from '../indexer/schema'; +import { AnyObject, EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { RepositorySearchClient } from './repository_search_client'; + +let repoSearchClient: RepositorySearchClient; +let esClient; + +// Setup the entire RepositorySearchClient. +function initSearchClient() { + const log: Logger = (sinon.stub() as any) as Logger; + esClient = initEsClient(); + + repoSearchClient = new RepositorySearchClient(esClient, log); +} + +const mockSearchResults = [ + // 1. The first response is a valid RepositorySearchResult with 2 repos + { + took: 1, + hits: { + total: { + value: 2, + }, + hits: [ + { + _source: { + [RepositoryReservedField]: { + uri: 'github.com/elastic/elasticsearch', + url: 'https://github.com/elastic/elasticsearch.git', + name: 'elasticsearch', + org: 'elastic', + }, + }, + }, + { + _source: { + [RepositoryReservedField]: { + uri: 'github.com/elastic/kibana', + url: 'https://github.com/elastic/kibana.git', + name: 'kibana', + org: 'elastic', + }, + }, + }, + ], + }, + }, + // 2. The second response is a valid RepositorySearchResult with 0 repos + { + took: 1, + hits: { + total: { + value: 0, + }, + hits: [], + }, + }, + // 3. The third response is an invalid RepositorySearchResult with results + // but without the RepositoryReservedField + { + took: 1, + hits: { + total: { + value: 1, + }, + hits: [ + { + _source: {}, + }, + ], + }, + }, +]; + +// Setup the mock EsClient. +function initEsClient(): EsClient { + esClient = { + search: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, + }; + const searchStub = sinon.stub(esClient, 'search'); + + // Binding the mock search results to the stub. + mockSearchResults.forEach((result, index) => { + searchStub.onCall(index).returns(Promise.resolve(result)); + }); + + return (esClient as any) as EsClient; +} + +beforeEach(() => { + initSearchClient(); +}); + +test('Repository search', async () => { + // 1. The first response should have 2 results. + const responseWithResult = await repoSearchClient.search({ query: 'mockQuery', page: 1 }); + expect(responseWithResult.repositories.length).toEqual(2); + expect(responseWithResult.total).toEqual(2); + expect(responseWithResult.repositories[0]).toEqual({ + uri: 'github.com/elastic/elasticsearch', + url: 'https://github.com/elastic/elasticsearch.git', + name: 'elasticsearch', + org: 'elastic', + }); + expect(responseWithResult.repositories[1]).toEqual({ + uri: 'github.com/elastic/kibana', + url: 'https://github.com/elastic/kibana.git', + name: 'kibana', + org: 'elastic', + }); + + // 2. The second response should have 0 result. + const responseWithEmptyResult = await repoSearchClient.search({ query: 'mockQuery', page: 1 }); + expect(responseWithEmptyResult.repositories.length).toEqual(0); + expect(responseWithEmptyResult.total).toEqual(0); + + // 3. The third response should have 1 hit, but 0 RepositorySearchResults, because the result + // does not have the RepositoryReservedField. + const responseWithInvalidResult = await repoSearchClient.search({ query: 'mockQuery', page: 1 }); + expect(responseWithInvalidResult.repositories.length).toEqual(0); + expect(responseWithInvalidResult.total).toEqual(1); +}); diff --git a/x-pack/plugins/code/server/search/repository_search_client.ts b/x-pack/plugins/code/server/search/repository_search_client.ts new file mode 100644 index 000000000000000..f7feb3d5d371dd9 --- /dev/null +++ b/x-pack/plugins/code/server/search/repository_search_client.ts @@ -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 { Repository, RepositorySearchRequest, RepositorySearchResult } from '../../model'; +import { + RepositoryIndexNamePrefix, + RepositoryReservedField, + RepositorySearchIndexWithScope, +} from '../indexer/schema'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { AbstractSearchClient } from './abstract_search_client'; + +export class RepositorySearchClient extends AbstractSearchClient { + constructor(protected readonly client: EsClient, protected readonly log: Logger) { + super(client, log); + } + + public async search(req: RepositorySearchRequest): Promise { + const resultsPerPage = this.getResultsPerPage(req); + const from = (req.page - 1) * resultsPerPage; + const size = resultsPerPage; + + const index = req.repoScope + ? RepositorySearchIndexWithScope(req.repoScope) + : `${RepositoryIndexNamePrefix}*`; + + const rawRes = await this.client.search({ + index, + body: { + from, + size, + query: { + bool: { + should: [ + { + simple_query_string: { + query: req.query, + fields: [ + `${RepositoryReservedField}.name^1.0`, + `${RepositoryReservedField}.org^1.0`, + ], + default_operator: 'or', + lenient: false, + analyze_wildcard: false, + boost: 1.0, + }, + }, + // This prefix query is mostly for typeahead search. + { + prefix: { + [`${RepositoryReservedField}.name`]: { + value: req.query, + boost: 100.0, + }, + }, + }, + ], + disable_coord: false, + adjust_pure_negative: true, + boost: 1.0, + }, + }, + }, + }); + + const hits: any[] = rawRes.hits.hits; + const repos: Repository[] = hits + .filter(hit => hit._source[RepositoryReservedField]) + .map(hit => { + const repo: Repository = hit._source[RepositoryReservedField]; + return repo; + }); + const total = rawRes.hits.total.value; + return { + repositories: repos, + took: rawRes.took, + total, + from, + page: req.page, + totalPage: Math.ceil(total / resultsPerPage), + }; + } +} diff --git a/x-pack/plugins/code/server/search/search_client.ts b/x-pack/plugins/code/server/search/search_client.ts new file mode 100644 index 000000000000000..a98d468d59b5fc7 --- /dev/null +++ b/x-pack/plugins/code/server/search/search_client.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 { SearchRequest, SearchResult } from '../../model'; + +export interface SearchClient { + search(req: SearchRequest): Promise; +} diff --git a/x-pack/plugins/code/server/search/symbol_search_client.test.ts b/x-pack/plugins/code/server/search/symbol_search_client.test.ts new file mode 100644 index 000000000000000..7df66dc3181c042 --- /dev/null +++ b/x-pack/plugins/code/server/search/symbol_search_client.test.ts @@ -0,0 +1,168 @@ +/* + * 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 sinon from 'sinon'; + +import { AnyObject, EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { SymbolSearchClient } from './symbol_search_client'; + +let symbolSearchClient: SymbolSearchClient; +let esClient; + +// Setup the entire SymbolSearchClient. +function initSearchClient() { + const log: Logger = (sinon.stub() as any) as Logger; + esClient = initEsClient(); + + symbolSearchClient = new SymbolSearchClient(esClient, log); +} + +const mockSearchResults = [ + // 1. The first response is a valid SymbolSearchResult with 2 symbols + { + took: 1, + hits: { + total: { + value: 2, + }, + hits: [ + { + _source: { + qname: 'copyStaticAssets.shell', + symbolInformation: { + name: 'shell', + kind: 13, + location: { + uri: + 'git://github.com/Microsoft/TypeScript-Node-Starter/blob/4779cb7/copyStaticAssets.ts', + range: { + start: { + line: 0, + character: 7, + }, + end: { + line: 0, + character: 17, + }, + }, + }, + containerName: 'copyStaticAssets', + }, + }, + }, + { + _source: { + qname: 'app.apiController', + symbolInformation: { + name: 'apiController', + kind: 13, + location: { + uri: 'git://github.com/Microsoft/TypeScript-Node-Starter/blob/4779cb7/src/app.ts', + range: { + start: { + line: 24, + character: 7, + }, + end: { + line: 24, + character: 25, + }, + }, + }, + containerName: 'app', + }, + }, + }, + ], + }, + }, + // 2. The second response is a valid SymbolSearchResult with 0 symbol. + { + took: 1, + hits: { + total: { + value: 0, + }, + hits: [], + }, + }, +]; + +// Setup the mock EsClient. +function initEsClient(): EsClient { + esClient = { + search: async (_: AnyObject): Promise => { + Promise.resolve({}); + }, + }; + const searchStub = sinon.stub(esClient, 'search'); + + // Binding the mock search results to the stub. + mockSearchResults.forEach((result, index) => { + searchStub.onCall(index).returns(Promise.resolve(result)); + }); + + return (esClient as any) as EsClient; +} + +beforeEach(() => { + initSearchClient(); +}); + +test('Symbol suggest/typeahead', async () => { + // 1. The first response should have 2 results. + const responseWithResult = await symbolSearchClient.suggest({ query: 'mockQuery', page: 1 }); + expect(responseWithResult.symbols.length).toEqual(2); + expect(responseWithResult.total).toEqual(2); + expect(responseWithResult.symbols[0]).toEqual({ + qname: 'copyStaticAssets.shell', + symbolInformation: { + name: 'shell', + kind: 13, + location: { + uri: 'git://github.com/Microsoft/TypeScript-Node-Starter/blob/4779cb7/copyStaticAssets.ts', + range: { + start: { + line: 0, + character: 7, + }, + end: { + line: 0, + character: 17, + }, + }, + }, + containerName: 'copyStaticAssets', + }, + }); + expect(responseWithResult.symbols[1]).toEqual({ + qname: 'app.apiController', + symbolInformation: { + name: 'apiController', + kind: 13, + location: { + uri: 'git://github.com/Microsoft/TypeScript-Node-Starter/blob/4779cb7/src/app.ts', + range: { + start: { + line: 24, + character: 7, + }, + end: { + line: 24, + character: 25, + }, + }, + }, + containerName: 'app', + }, + }); + + // 2. The second response should have 0 results. + const responseWithEmptyResult = await symbolSearchClient.suggest({ query: 'mockQuery', page: 1 }); + expect(responseWithEmptyResult.symbols.length).toEqual(0); + expect(responseWithEmptyResult.total).toEqual(0); +}); diff --git a/x-pack/plugins/code/server/search/symbol_search_client.ts b/x-pack/plugins/code/server/search/symbol_search_client.ts new file mode 100644 index 000000000000000..5d0d285d73ae21c --- /dev/null +++ b/x-pack/plugins/code/server/search/symbol_search_client.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 { DetailSymbolInformation } from '@elastic/lsp-extension'; + +import { SymbolSearchRequest, SymbolSearchResult } from '../../model'; +import { SymbolIndexNamePrefix, SymbolSearchIndexWithScope } from '../indexer/schema'; +import { EsClient } from '../lib/esqueue'; +import { Logger } from '../log'; +import { AbstractSearchClient } from './abstract_search_client'; + +export class SymbolSearchClient extends AbstractSearchClient { + constructor(protected readonly client: EsClient, protected readonly log: Logger) { + super(client, log); + } + + public async findByQname(qname: string): Promise { + const [from, size] = [0, 1]; + const rawRes = await this.client.search({ + index: `${SymbolIndexNamePrefix}*`, + body: { + from, + size, + query: { + term: { + qname, + }, + }, + }, + }); + return this.handleResults(rawRes); + } + + public async suggest(req: SymbolSearchRequest): Promise { + const resultsPerPage = this.getResultsPerPage(req); + const from = (req.page - 1) * resultsPerPage; + const size = resultsPerPage; + + const index = req.repoScope + ? SymbolSearchIndexWithScope(req.repoScope) + : `${SymbolIndexNamePrefix}*`; + + const rawRes = await this.client.search({ + index, + body: { + from, + size, + query: { + bool: { + should: [ + // Boost more for case sensitive prefix query. + { + prefix: { + qname: { + value: req.query, + boost: 2.0, + }, + }, + }, + // Boost less for lowercased prefix query. + { + prefix: { + 'qname.lowercased': { + // prefix query does not apply analyzer for query. so manually lowercase the query in here. + value: req.query.toLowerCase(), + boost: 1.0, + }, + }, + }, + // Boost the exact match with case sensitive query the most. + { + term: { + qname: { + value: req.query, + boost: 20.0, + }, + }, + }, + { + term: { + 'qname.lowercased': { + // term query does not apply analyzer for query either. so manually lowercase the query in here. + value: req.query.toLowerCase(), + boost: 10.0, + }, + }, + }, + // The same applies for `symbolInformation.name` feild. + { + prefix: { + 'symbolInformation.name': { + value: req.query, + boost: 2.0, + }, + }, + }, + { + prefix: { + 'symbolInformation.name.lowercased': { + value: req.query.toLowerCase(), + boost: 1.0, + }, + }, + }, + { + term: { + 'symbolInformation.name': { + value: req.query, + boost: 20.0, + }, + }, + }, + { + term: { + 'symbolInformation.name.lowercased': { + value: req.query.toLowerCase(), + boost: 10.0, + }, + }, + }, + ], + disable_coord: false, + adjust_pure_negative: true, + boost: 1.0, + }, + }, + }, + }); + + return this.handleResults(rawRes); + } + + private handleResults(rawRes: any) { + const hits: any[] = rawRes.hits.hits; + const symbols: DetailSymbolInformation[] = hits.map(hit => { + const symbol: DetailSymbolInformation = hit._source; + return symbol; + }); + const result: SymbolSearchResult = { + symbols, + took: rawRes.took, + total: rawRes.hits.total.value, + }; + return result; + } +} diff --git a/x-pack/plugins/code/server/security.ts b/x-pack/plugins/code/server/security.ts new file mode 100644 index 000000000000000..50368117904aba4 --- /dev/null +++ b/x-pack/plugins/code/server/security.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 { Server, ServerRoute, RouteOptions } from 'hapi'; + +export class CodeServerRouter { + constructor(readonly server: Server) {} + + route(route: CodeRoute) { + const routeOptions: RouteOptions = (route.options || {}) as RouteOptions; + routeOptions.tags = [ + ...(routeOptions.tags || []), + `access:code_${route.requireAdmin ? 'admin' : 'user'}`, + ]; + + this.server.route({ + handler: route.handler, + method: route.method, + options: routeOptions, + path: route.path, + }); + } +} + +export interface CodeRoute extends ServerRoute { + requireAdmin?: boolean; +} diff --git a/x-pack/plugins/code/server/server_options.ts b/x-pack/plugins/code/server/server_options.ts new file mode 100644 index 000000000000000..9db07296d425264 --- /dev/null +++ b/x-pack/plugins/code/server/server_options.ts @@ -0,0 +1,69 @@ +/* + * 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 { resolve } from 'path'; +import { RepoConfig, RepoConfigs } from '../model'; + +export interface LspOptions { + requestTimeoutMs: number; + detach: boolean; + verbose: boolean; +} + +export interface SecurityOptions { + enableMavenImport: boolean; + enableGradleImport: boolean; + installNodeDependency: boolean; + gitHostWhitelist: string[]; + gitProtocolWhitelist: string[]; + enableGitCertCheck: boolean; +} + +export class ServerOptions { + public readonly workspacePath = resolve(this.config.get('path.data'), 'code/workspace'); + + public readonly repoPath = resolve(this.config.get('path.data'), 'code/repos'); + + public readonly credsPath = resolve(this.config.get('path.data'), 'code/credentials'); + + public readonly langServerPath = resolve(this.config.get('path.data'), 'code/langserver'); + + public readonly jdtWorkspacePath = resolve(this.config.get('path.data'), 'code/jdt_ws'); + + public readonly jdtConfigPath = resolve(this.config.get('path.data'), 'code/jdt_config'); + + public readonly updateFrequencyMs: number = this.options.updateFrequencyMs; + + public readonly indexFrequencyMs: number = this.options.indexFrequencyMs; + + public readonly updateRepoFrequencyMs: number = this.options.updateRepoFrequencyMs; + + public readonly indexRepoFrequencyMs: number = this.options.indexRepoFrequencyMs; + + public readonly maxWorkspace: number = this.options.maxWorkspace; + + public readonly disableIndexScheduler: boolean = this.options.disableIndexScheduler; + + public readonly enableGlobalReference: boolean = this.options.enableGlobalReference; + + public readonly lsp: LspOptions = this.options.lsp; + + public readonly security: SecurityOptions = this.options.security; + + public readonly repoConfigs: RepoConfigs = (this.options.repos as RepoConfig[]).reduce( + (previous, current) => { + previous[current.repo] = current; + return previous; + }, + {} as RepoConfigs + ); + + public readonly enabled: boolean = this.options.enabled; + + public readonly codeNodeUrl: string = this.options.codeNodeUrl; + + constructor(private options: any, private config: any) {} +} diff --git a/x-pack/plugins/code/server/test_utils.ts b/x-pack/plugins/code/server/test_utils.ts new file mode 100644 index 000000000000000..2ca2ce53a1f3569 --- /dev/null +++ b/x-pack/plugins/code/server/test_utils.ts @@ -0,0 +1,54 @@ +/* + * 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 * as os from 'os'; +import path from 'path'; + +import { AnyObject } from './lib/esqueue'; +import { ServerOptions } from './server_options'; + +// TODO migrate other duplicate classes, functions + +export const emptyAsyncFunc = async (_: AnyObject): Promise => { + Promise.resolve({}); +}; + +const TEST_OPTIONS = { + enabled: true, + queueIndex: '.code_internal-worker-queue', + queueTimeout: 60 * 60 * 1000, // 1 hour by default + updateFreqencyMs: 5 * 60 * 1000, // 5 minutes by default + indexFrequencyMs: 24 * 60 * 60 * 1000, // 1 day by default + lsp: { + requestTimeoutMs: 5 * 60, // timeout a request over 30s + detach: false, + verbose: false, + }, + security: { + enableMavenImport: true, + enableGradleImport: true, + installNodeDependency: true, + enableGitCertCheck: false, + }, + repos: [], + maxWorkspace: 5, // max workspace folder for each language server + disableIndexScheduler: true, // Temp option to disable index scheduler. +}; + +export function createTestServerOption() { + const tmpDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'code_test')); + + const config = { + get(key: string) { + if (key === 'path.data') { + return tmpDataPath; + } + }, + }; + + return new ServerOptions(TEST_OPTIONS, config); +} diff --git a/x-pack/plugins/code/server/utils/buffer.test.ts b/x-pack/plugins/code/server/utils/buffer.test.ts new file mode 100644 index 000000000000000..d06279080958a9a --- /dev/null +++ b/x-pack/plugins/code/server/utils/buffer.test.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 { extractLines } from './buffer'; +const text = `line0 +line1 +line2 +line3 +line4`; + +const buffer = Buffer.from(text); + +test('extract first line from buffer', () => { + const lines = extractLines(buffer, 0, 1); + expect(lines).toEqual('line0'); +}); + +test('extract 2 lines from buffer', () => { + const lines = extractLines(buffer, 1, 3); + expect(lines).toEqual('line1\nline2'); +}); + +test('extract all lines from buffer', () => { + const lines = extractLines(buffer, 0, Number.MAX_VALUE); + expect(lines).toEqual(text); +}); + +test('extract at least one line', () => { + const oneLineText = Buffer.from('line0'); + const lines = extractLines(oneLineText, 0, 1); + expect(lines).toEqual('line0'); +}); diff --git a/x-pack/plugins/code/server/utils/buffer.ts b/x-pack/plugins/code/server/utils/buffer.ts new file mode 100644 index 000000000000000..67db68a0a528a27 --- /dev/null +++ b/x-pack/plugins/code/server/utils/buffer.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +const LF = '\n'.charCodeAt(0); +const CR = '\r'.charCodeAt(0); + +export function extractLines(buf: Buffer, fromLine: number, toLine: number) { + let currentLine = 0; + let fromIdx = 0; + let toIdx = buf.length; + let lastChar = -1; + for (const [idx, char] of buf.entries()) { + if (char === LF) { + currentLine++; + if (currentLine === fromLine) { + fromIdx = idx + 1; + } + if (currentLine === toLine) { + // line-break is CRLF under windows + if (lastChar === CR) { + toIdx = idx - 1; + } else { + toIdx = idx; + } + break; + } + } + lastChar = char; + } + return buf.toString('utf8', fromIdx, toIdx); +} diff --git a/x-pack/plugins/code/server/utils/composite_source_merger.test.ts b/x-pack/plugins/code/server/utils/composite_source_merger.test.ts new file mode 100644 index 000000000000000..91adfd6372e0e02 --- /dev/null +++ b/x-pack/plugins/code/server/utils/composite_source_merger.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { + expandRanges, + extractSourceContent, + LineMapping, + mergeRanges, +} from './composite_source_merger'; + +function asRanges(array: number[][]) { + return array.map(v => { + const [startLine, endLine] = v; + return { startLine, endLine }; + }); +} + +test('expand lines to ranges', () => { + const lines = asRanges([[1, 1], [3, 3], [5, 6]]); + const expectedRanges = asRanges([[0, 4], [1, 6], [3, 9]]); + expect(expandRanges(lines, 2)).toEqual(expectedRanges); +}); + +test('merge ranges', () => { + const ranges = asRanges([[0, 3], [2, 5], [6, 7], [7, 10]]); + const expectedRanges = asRanges([[0, 5], [6, 10]]); + expect(mergeRanges(ranges)).toEqual(expectedRanges); +}); + +test('extract source by ranges', () => { + const source = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; + const ranges = asRanges([[0, 3], [7, 11]]); + const lineMappings = new LineMapping(); + const extracted = extractSourceContent(ranges, source, lineMappings); + expect(extracted).toEqual(['0', '1', '2', '', '7', '8', '9', '10']); + const expectedLineNumbers = ['1', '2', '3', '...', '8', '9', '10', '11']; + expect(lineMappings.toStringArray('...')).toEqual(expectedLineNumbers); + expect(lineMappings.lineNumber(7)).toBe(5); + expect(lineMappings.lineNumber(0)).toBe(1); +}); diff --git a/x-pack/plugins/code/server/utils/composite_source_merger.ts b/x-pack/plugins/code/server/utils/composite_source_merger.ts new file mode 100644 index 000000000000000..982e77a28cd93d6 --- /dev/null +++ b/x-pack/plugins/code/server/utils/composite_source_merger.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. + */ + +export const DEFAULT_LINE_NUMBER_PLACEHOLDER = '..'; + +export interface LineRange { + startLine: number; + endLine: number; +} + +/** + * expand ranges of lines, eg: + * expand 2 lines of [[3,4], [9,9]] with value 2 becomes [(1,7), (7,12)] + * @param lines the array of line numbers + * @param expand the expand range + */ +export function expandRanges(lines: LineRange[], expand: number): LineRange[] { + return lines.map(line => ({ + startLine: Math.max(0, line.startLine - expand), + endLine: line.endLine + expand + 1, + })); +} + +/** + * merge the ranges that overlap each other into one, eg: + * [(1,3), (2,5)] => [(1,5)] + * @param ranges + */ +export function mergeRanges(ranges: LineRange[]): LineRange[] { + const sortedRanges = ranges.sort((a, b) => a.startLine - b.startLine); + const results: LineRange[] = []; + + const mergeIfOverlap = (a: LineRange, b: LineRange) => { + // a <= b is always true here because sorting above + if (b.startLine >= a.startLine && b.startLine <= a.endLine) { + // overlap + if (b.endLine > a.endLine) { + a.endLine = b.endLine; // extend previous range + } + return true; + } + return false; + }; + + for (const range of sortedRanges) { + if (results.length > 0) { + const last = results[results.length - 1]; + if (mergeIfOverlap(last, range)) { + continue; + } + } + results.push(range); + } + return results; +} + +/** + * extract content from source by ranges + * @param ranges the partials ranges of contents + * @param source the source content + * @param mapper a line mapper object which contains the relationship between source content and partial content + * @return the extracted partial contents + * #todo To support server side render for grammar highlights, we could change the source parameter to HighlightedCode[]. + * #todo interface HighlightedCode { text: string, highlights: ... }; + */ +export function extractSourceContent( + ranges: LineRange[], + source: string[], + mapper: LineMapping +): string[] { + const sortedRanges = ranges.sort((a, b) => a.startLine - b.startLine); + let results: string[] = []; + const pushPlaceholder = () => { + results.push(''); + mapper.addPlaceholder(); + }; + for (const range of sortedRanges) { + if (!(results.length === 0 && range.startLine === 0)) { + pushPlaceholder(); + } + const slicedContent = source.slice(range.startLine, range.endLine); + results = results.concat(slicedContent); + mapper.addMapping(range.startLine, range.startLine + slicedContent.length); + } + const lastRange = sortedRanges[sortedRanges.length - 1]; + if (lastRange.endLine < source.length) { + pushPlaceholder(); + } + return results; +} + +export class LineMapping { + private lines: number[] = []; + private reverseMap: Map = new Map(); + public toStringArray( + placeHolder: string = DEFAULT_LINE_NUMBER_PLACEHOLDER, + startAtLine: number = 1 + ): string[] { + return this.lines.map(v => { + if (Number.isNaN(v)) { + return placeHolder; + } else { + return (v + startAtLine).toString(); + } + }); + } + public addPlaceholder() { + this.lines.push(Number.NaN); + } + + public addMapping(start: number, end: number) { + for (let i = start; i < end; i++) { + this.lines.push(i); + this.reverseMap.set(i, this.lines.length - 1); + } + } + + public lineNumber(originLineNumber: number, startAtLine: number = 1) { + const n = this.reverseMap.get(originLineNumber); + if (n === undefined) { + return Number.NaN; + } else { + return n + startAtLine; + } + } + + public hasLine(line: number) { + return this.reverseMap.has(line); + } +} diff --git a/x-pack/plugins/code/server/utils/console_logger.ts b/x-pack/plugins/code/server/utils/console_logger.ts new file mode 100644 index 000000000000000..1a0292e6f2b5955 --- /dev/null +++ b/x-pack/plugins/code/server/utils/console_logger.ts @@ -0,0 +1,43 @@ +/* + * 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 no-console */ + +import { Logger } from '../log'; + +export class ConsoleLogger extends Logger { + constructor() { + // @ts-ignore + super(undefined); + } + + public info(msg: string | any) { + console.info(msg); + } + + public error(msg: string | any) { + console.error(msg); + } + + public log(message: string): void { + this.info(message); + } + + public debug(msg: string | any) { + console.debug(msg); + } + + public warn(msg: string | any): void { + console.warn(msg); + } + + public stdout(msg: string | any) { + console.info(msg); + } + + public stderr(msg: string | any) { + console.error(msg); + } +} diff --git a/x-pack/plugins/code/server/utils/console_logger_factory.ts b/x-pack/plugins/code/server/utils/console_logger_factory.ts new file mode 100644 index 000000000000000..6dc1f211902efc1 --- /dev/null +++ b/x-pack/plugins/code/server/utils/console_logger_factory.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. + */ + +import { Logger } from '../log'; +import { ConsoleLogger } from './console_logger'; +import { LoggerFactory } from './log_factory'; + +export class ConsoleLoggerFactory implements LoggerFactory { + public getLogger(tags: string[]): Logger { + return new ConsoleLogger(); + } +} diff --git a/x-pack/plugins/code/server/utils/detect_language.ts b/x-pack/plugins/code/server/utils/detect_language.ts new file mode 100644 index 000000000000000..0cde59d550baf40 --- /dev/null +++ b/x-pack/plugins/code/server/utils/detect_language.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 path from 'path'; + +import * as extensions from './extensions.json'; + +const languageMap: { [key: string]: string } = extensions; + +// patch the lib +languageMap['.ts'] = 'typescript'; +languageMap['.tsx'] = 'typescript'; + +function detectByFilename(file: string): string { + const ext = path.extname(file); + if (ext) { + return languageMap[ext]; + } + return 'other'; // TODO: if how should we deal with other types? +} + +// function readFile(file: string) { +// return new Promise((resolve, reject) => +// fs.readFile(file, 'utf8', (err, content) => { +// if (err) { +// reject(err); +// } else { +// resolve(content); +// } +// }) +// ); +// } + +export function detectLanguageByFilename(filename: string) { + const lang = detectByFilename(filename); + return lang && lang.toLowerCase(); +} + +export async function detectLanguage(file: string, fileContent?: Buffer | string): Promise { + const lang = detectByFilename(file); + return await Promise.resolve(lang ? lang.toLowerCase() : null); + // if (!lang) { + // let content: string; + // if (fileContent) { + // content = typeof fileContent === 'string' ? fileContent : fileContent.toString('utf8'); + // } else { + // content = await readFile(file); + // } + // lang = detect.contents(file, content); + // return lang ? lang.toLowerCase() : null; + // } else { + // return Promise.resolve(lang.toLowerCase()); + // } +} diff --git a/x-pack/plugins/code/server/utils/es_index_client.ts b/x-pack/plugins/code/server/utils/es_index_client.ts new file mode 100644 index 000000000000000..6a4c76d0056cc32 --- /dev/null +++ b/x-pack/plugins/code/server/utils/es_index_client.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 { AnyObject } from '../lib/esqueue'; +import { WithRequest } from './with_request'; + +export class EsIndexClient { + constructor(readonly self: WithRequest) {} + + public exists(params: AnyObject): Promise { + return this.self.callCluster('indices.exists', params); + } + + public create(params: AnyObject): Promise { + return this.self.callCluster('indices.create', params); + } + + public refresh(params: AnyObject): Promise { + return this.self.callCluster('indices.refresh', params); + } + + public delete(params: AnyObject): Promise { + return this.self.callCluster('indices.delete', params); + } + + public existsAlias(params: AnyObject): Promise { + return this.self.callCluster('indices.existsAlias', params); + } + + public getAlias(params: AnyObject): Promise { + return this.self.callCluster('indices.getAlias', params); + } + + public putAlias(params: AnyObject): Promise { + return this.self.callCluster('indices.putAlias', params); + } + + public deleteAlias(params: AnyObject): Promise { + return this.self.callCluster('indices.deleteAlias', params); + } + + public updateAliases(params: AnyObject): Promise { + return this.self.callCluster('indices.updateAliases', params); + } + + public getMapping(params: AnyObject): Promise { + return this.self.callCluster('indices.getMapping', params); + } +} diff --git a/x-pack/plugins/code/server/utils/esclient_with_request.ts b/x-pack/plugins/code/server/utils/esclient_with_request.ts new file mode 100644 index 000000000000000..cb825a6543fa7ba --- /dev/null +++ b/x-pack/plugins/code/server/utils/esclient_with_request.ts @@ -0,0 +1,54 @@ +/* + * 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 { Request } from 'hapi'; +import { AnyObject, EsClient } from '../lib/esqueue'; +import { EsIndexClient } from './es_index_client'; +import { WithRequest } from './with_request'; + +export class EsClientWithRequest extends WithRequest implements EsClient { + public readonly indices = new EsIndexClient(this); + + constructor(readonly req: Request) { + super(req); + } + + public bulk(params: AnyObject): Promise { + return this.callCluster('bulk', params); + } + + public delete(params: AnyObject): Promise { + return this.callCluster('delete', params); + } + + public deleteByQuery(params: AnyObject): Promise { + return this.callCluster('deleteByQuery', params); + } + + public get(params: AnyObject): Promise { + return this.callCluster('get', params); + } + + public index(params: AnyObject): Promise { + return this.callCluster('index', params); + } + + public ping(): Promise { + return this.callCluster('ping'); + } + + public reindex(params: AnyObject): Promise { + return this.callCluster('reindex', params); + } + + public search(params: AnyObject): Promise { + return this.callCluster('search', params); + } + + public update(params: AnyObject): Promise { + return this.callCluster('update', params); + } +} diff --git a/x-pack/plugins/code/server/utils/extensions.json b/x-pack/plugins/code/server/utils/extensions.json new file mode 100644 index 000000000000000..903ba4e18d4a21b --- /dev/null +++ b/x-pack/plugins/code/server/utils/extensions.json @@ -0,0 +1,846 @@ +{ + ".abap": "ABAP", + ".asc": "Public Key", + ".ash": "AGS Script", + ".ampl": "AMPL", + ".mod": "XML", + ".g4": "ANTLR", + ".apib": "API Blueprint", + ".apl": "APL", + ".dyalog": "APL", + ".asp": "ASP", + ".asax": "ASP", + ".ascx": "ASP", + ".ashx": "ASP", + ".asmx": "ASP", + ".aspx": "ASP", + ".axd": "ASP", + ".dats": "ATS", + ".hats": "ATS", + ".sats": "ATS", + ".as": "ActionScript", + ".adb": "Ada", + ".ada": "Ada", + ".ads": "Ada", + ".agda": "Agda", + ".als": "Alloy", + ".apacheconf": "ApacheConf", + ".vhost": "Nginx", + ".cls": "Visual Basic", + ".applescript": "AppleScript", + ".scpt": "AppleScript", + ".arc": "Arc", + ".ino": "Arduino", + ".asciidoc": "AsciiDoc", + ".adoc": "AsciiDoc", + ".aj": "AspectJ", + ".asm": "Assembly", + ".a51": "Assembly", + ".inc": "SourcePawn", + ".nasm": "Assembly", + ".aug": "Augeas", + ".ahk": "AutoHotkey", + ".ahkl": "AutoHotkey", + ".au3": "AutoIt", + ".awk": "Awk", + ".auk": "Awk", + ".gawk": "Awk", + ".mawk": "Awk", + ".nawk": "Awk", + ".bat": "Batchfile", + ".cmd": "Batchfile", + ".befunge": "Befunge", + ".bison": "Bison", + ".bb": "BlitzBasic", + ".decls": "BlitzBasic", + ".bmx": "BlitzMax", + ".bsv": "Bluespec", + ".boo": "Boo", + ".b": "Limbo", + ".bf": "HyPhy", + ".brs": "Brightscript", + ".bro": "Bro", + ".c": "C", + ".cats": "C", + ".h": "Objective-C", + ".idc": "C", + ".w": "C", + ".cs": "Smalltalk", + ".cshtml": "C#", + ".csx": "C#", + ".cpp": "C++", + ".c++": "C++", + ".cc": "C++", + ".cp": "Component Pascal", + ".cxx": "C++", + ".h++": "C++", + ".hh": "Hack", + ".hpp": "C++", + ".hxx": "C++", + ".inl": "C++", + ".ipp": "C++", + ".tcc": "C++", + ".tpp": "C++", + ".c-objdump": "C-ObjDump", + ".chs": "C2hs Haskell", + ".clp": "CLIPS", + ".cmake": "CMake", + ".cmake.in": "CMake", + ".cob": "COBOL", + ".cbl": "COBOL", + ".ccp": "COBOL", + ".cobol": "COBOL", + ".cpy": "COBOL", + ".css": "CSS", + ".capnp": "Cap'n Proto", + ".mss": "CartoCSS", + ".ceylon": "Ceylon", + ".chpl": "Chapel", + ".ch": "xBase", + ".ck": "ChucK", + ".cirru": "Cirru", + ".clw": "Clarion", + ".icl": "Clean", + ".dcl": "Clean", + ".clj": "Clojure", + ".boot": "Clojure", + ".cl2": "Clojure", + ".cljc": "Clojure", + ".cljs": "Clojure", + ".cljs.hl": "Clojure", + ".cljscm": "Clojure", + ".cljx": "Clojure", + ".hic": "Clojure", + ".coffee": "CoffeeScript", + "._coffee": "CoffeeScript", + ".cjsx": "CoffeeScript", + ".cson": "CoffeeScript", + ".iced": "CoffeeScript", + ".cfm": "ColdFusion", + ".cfml": "ColdFusion", + ".cfc": "ColdFusion CFC", + ".lisp": "NewLisp", + ".asd": "Common Lisp", + ".cl": "OpenCL", + ".l": "PicoLisp", + ".lsp": "NewLisp", + ".ny": "Common Lisp", + ".podsl": "Common Lisp", + ".sexp": "Common Lisp", + ".cps": "Component Pascal", + ".coq": "Coq", + ".v": "Verilog", + ".cppobjdump": "Cpp-ObjDump", + ".c++-objdump": "Cpp-ObjDump", + ".c++objdump": "Cpp-ObjDump", + ".cpp-objdump": "Cpp-ObjDump", + ".cxx-objdump": "Cpp-ObjDump", + ".creole": "Creole", + ".cr": "Crystal", + ".feature": "Cucumber", + ".cu": "Cuda", + ".cuh": "Cuda", + ".cy": "Cycript", + ".pyx": "Cython", + ".pxd": "Cython", + ".pxi": "Cython", + ".d": "Makefile", + ".di": "D", + ".d-objdump": "D-ObjDump", + ".com": "DIGITAL Command Language", + ".dm": "DM", + ".zone": "DNS Zone", + ".arpa": "DNS Zone", + ".darcspatch": "Darcs Patch", + ".dpatch": "Darcs Patch", + ".dart": "Dart", + ".diff": "Diff", + ".patch": "Diff", + ".dockerfile": "Dockerfile", + ".djs": "Dogescript", + ".dylan": "Dylan", + ".dyl": "Dylan", + ".intr": "Dylan", + ".lid": "Dylan", + ".e": "Eiffel", + ".ecl": "ECLiPSe", + ".eclxml": "ECL", + ".sch": "KiCad", + ".brd": "Eagle", + ".epj": "Ecere Projects", + ".ex": "Elixir", + ".exs": "Elixir", + ".elm": "Elm", + ".el": "Emacs Lisp", + ".emacs": "Emacs Lisp", + ".emacs.desktop": "Emacs Lisp", + ".em": "EmberScript", + ".emberscript": "EmberScript", + ".erl": "Erlang", + ".es": "Erlang", + ".escript": "Erlang", + ".hrl": "Erlang", + ".fs": "GLSL", + ".fsi": "F#", + ".fsx": "F#", + ".fx": "FLUX", + ".flux": "FLUX", + ".f90": "FORTRAN", + ".f": "Forth", + ".f03": "FORTRAN", + ".f08": "FORTRAN", + ".f77": "FORTRAN", + ".f95": "FORTRAN", + ".for": "Forth", + ".fpp": "FORTRAN", + ".factor": "Factor", + ".fy": "Fancy", + ".fancypack": "Fancy", + ".fan": "Fantom", + ".fth": "Forth", + ".4th": "Forth", + ".forth": "Forth", + ".fr": "Text", + ".frt": "Forth", + ".g": "GAP", + ".gco": "G-code", + ".gcode": "G-code", + ".gms": "GAMS", + ".gap": "GAP", + ".gd": "GDScript", + ".gi": "GAP", + ".tst": "Scilab", + ".s": "GAS", + ".ms": "Groff", + ".glsl": "GLSL", + ".fp": "GLSL", + ".frag": "JavaScript", + ".frg": "GLSL", + ".fshader": "GLSL", + ".geo": "GLSL", + ".geom": "GLSL", + ".glslv": "GLSL", + ".gshader": "GLSL", + ".shader": "GLSL", + ".vert": "GLSL", + ".vrx": "GLSL", + ".vshader": "GLSL", + ".gml": "XML", + ".kid": "Genshi", + ".ebuild": "Gentoo Ebuild", + ".eclass": "Gentoo Eclass", + ".po": "Gettext Catalog", + ".pot": "Gettext Catalog", + ".glf": "Glyph", + ".gp": "Gnuplot", + ".gnu": "Gnuplot", + ".gnuplot": "Gnuplot", + ".plot": "Gnuplot", + ".plt": "Gnuplot", + ".go": "Go", + ".golo": "Golo", + ".gs": "JavaScript", + ".gst": "Gosu", + ".gsx": "Gosu", + ".vark": "Gosu", + ".grace": "Grace", + ".gradle": "Gradle", + ".gf": "Grammatical Framework", + ".dot": "Graphviz (DOT)", + ".gv": "Graphviz (DOT)", + ".man": "Groff", + ".1": "Groff", + ".1in": "Groff", + ".1m": "Groff", + ".1x": "Groff", + ".2": "Groff", + ".3": "Groff", + ".3in": "Groff", + ".3m": "Groff", + ".3qt": "Groff", + ".3x": "Groff", + ".4": "Groff", + ".5": "Groff", + ".6": "Groff", + ".7": "Groff", + ".8": "Groff", + ".9": "Groff", + ".n": "Nemerle", + ".rno": "Groff", + ".roff": "Groff", + ".groovy": "Groovy", + ".grt": "Groovy", + ".gtpl": "Groovy", + ".gvy": "Groovy", + ".gsp": "Groovy Server Pages", + ".hcl": "HCL", + ".tf": "HCL", + ".html": "HTML", + ".htm": "HTML", + ".html.hl": "HTML", + ".st": "Smalltalk", + ".xht": "HTML", + ".xhtml": "HTML", + ".mustache": "HTML+Django", + ".jinja": "HTML+Django", + ".erb": "HTML+ERB", + ".erb.deface": "HTML+ERB", + ".phtml": "HTML+PHP", + ".http": "HTTP", + ".php": "PHP", + ".haml": "Haml", + ".haml.deface": "Haml", + ".handlebars": "Handlebars", + ".hbs": "Handlebars", + ".hb": "Harbour", + ".hs": "Haskell", + ".hsc": "Haskell", + ".hx": "Haxe", + ".hxsl": "Haxe", + ".hy": "Hy", + ".pro": "QMake", + ".dlm": "IDL", + ".ipf": "IGOR Pro", + ".ini": "INI", + ".cfg": "INI", + ".prefs": "INI", + ".properties": "INI", + ".irclog": "IRC log", + ".weechatlog": "IRC log", + ".idr": "Idris", + ".lidr": "Idris", + ".ni": "Inform 7", + ".i7x": "Inform 7", + ".iss": "Inno Setup", + ".io": "Io", + ".ik": "Ioke", + ".thy": "Isabelle", + ".ijs": "J", + ".flex": "JFlex", + ".jflex": "JFlex", + ".json": "JSON", + ".lock": "JSON", + ".json5": "JSON5", + ".jsonld": "JSONLD", + ".jq": "JSONiq", + ".jade": "Jade", + ".j": "Objective-J", + ".java": "Java", + ".jsp": "Java Server Pages", + ".js": "JavaScript", + "._js": "JavaScript", + ".bones": "JavaScript", + ".es6": "JavaScript", + ".jake": "JavaScript", + ".jsb": "JavaScript", + ".jsfl": "JavaScript", + ".jsm": "JavaScript", + ".jss": "JavaScript", + ".jsx": "JavaScript", + ".njs": "JavaScript", + ".pac": "JavaScript", + ".sjs": "JavaScript", + ".ssjs": "JavaScript", + ".sublime-build": "JavaScript", + ".sublime-commands": "JavaScript", + ".sublime-completions": "JavaScript", + ".sublime-keymap": "JavaScript", + ".sublime-macro": "JavaScript", + ".sublime-menu": "JavaScript", + ".sublime-mousemap": "JavaScript", + ".sublime-project": "JavaScript", + ".sublime-settings": "JavaScript", + ".sublime-theme": "JavaScript", + ".sublime-workspace": "JavaScript", + ".sublime_metrics": "JavaScript", + ".sublime_session": "JavaScript", + ".xsjs": "JavaScript", + ".xsjslib": "JavaScript", + ".jl": "Julia", + ".krl": "KRL", + ".kicad_pcb": "KiCad", + ".kit": "Kit", + ".kt": "Kotlin", + ".ktm": "Kotlin", + ".kts": "Kotlin", + ".lfe": "LFE", + ".ll": "LLVM", + ".lol": "LOLCODE", + ".lsl": "LSL", + ".lvproj": "LabVIEW", + ".lasso": "Lasso", + ".las": "Lasso", + ".lasso8": "Lasso", + ".lasso9": "Lasso", + ".ldml": "Lasso", + ".latte": "Latte", + ".lean": "Lean", + ".hlean": "Lean", + ".less": "Less", + ".lex": "Lex", + ".ly": "LilyPond", + ".ily": "LilyPond", + ".m": "Objective-C", + ".ld": "Linker Script", + ".lds": "Linker Script", + ".liquid": "Liquid", + ".lagda": "Literate Agda", + ".litcoffee": "Literate CoffeeScript", + ".lhs": "Literate Haskell", + ".ls": "LoomScript", + "._ls": "LiveScript", + ".xm": "Logos", + ".x": "Logos", + ".xi": "Logos", + ".lgt": "Logtalk", + ".logtalk": "Logtalk", + ".lookml": "LookML", + ".lua": "Lua", + ".fcgi": "Shell", + ".nse": "Lua", + ".pd_lua": "Lua", + ".rbxs": "Lua", + ".wlua": "Lua", + ".mumps": "M", + ".mtml": "MTML", + ".muf": "MUF", + ".mak": "Makefile", + ".mk": "Makefile", + ".mako": "Mako", + ".mao": "Mako", + ".md": "Markdown", + ".markdown": "Markdown", + ".mkd": "Markdown", + ".mkdn": "Markdown", + ".mkdown": "Markdown", + ".ron": "Markdown", + ".mask": "Mask", + ".mathematica": "Mathematica", + ".cdf": "Mathematica", + ".ma": "Mathematica", + ".nb": "Mathematica", + ".nbp": "Mathematica", + ".wl": "Mathematica", + ".wlt": "Mathematica", + ".matlab": "Matlab", + ".maxpat": "Max", + ".maxhelp": "Max", + ".maxproj": "Max", + ".mxt": "Max", + ".pat": "Max", + ".mediawiki": "MediaWiki", + ".moo": "Moocode", + ".minid": "MiniD", + ".druby": "Mirah", + ".duby": "Mirah", + ".mir": "Mirah", + ".mirah": "Mirah", + ".mo": "Modelica", + ".mms": "Module Management System", + ".mmk": "Module Management System", + ".monkey": "Monkey", + ".moon": "MoonScript", + ".myt": "Myghty", + ".ncl": "Text", + ".nl": "NewLisp", + ".nsi": "NSIS", + ".nsh": "NSIS", + ".axs": "NetLinx", + ".axi": "NetLinx", + ".axs.erb": "NetLinx+ERB", + ".axi.erb": "NetLinx+ERB", + ".nlogo": "NetLogo", + ".nginxconf": "Nginx", + ".nim": "Nimrod", + ".nimrod": "Nimrod", + ".ninja": "Ninja", + ".nit": "Nit", + ".nix": "Nix", + ".nu": "Nu", + ".numpy": "NumPy", + ".numpyw": "NumPy", + ".numsc": "NumPy", + ".ml": "Standard ML", + ".eliom": "OCaml", + ".eliomi": "OCaml", + ".ml4": "OCaml", + ".mli": "OCaml", + ".mll": "OCaml", + ".mly": "OCaml", + ".objdump": "ObjDump", + ".mm": "XML", + ".sj": "Objective-J", + ".omgrofl": "Omgrofl", + ".opa": "Opa", + ".opal": "Opal", + ".opencl": "OpenCL", + ".p": "OpenEdge ABL", + ".scad": "OpenSCAD", + ".org": "Org", + ".ox": "Ox", + ".oxh": "Ox", + ".oxo": "Ox", + ".oxygene": "Oxygene", + ".oz": "Oz", + ".pwn": "PAWN", + ".aw": "PHP", + ".ctp": "PHP", + ".php3": "PHP", + ".php4": "PHP", + ".php5": "PHP", + ".phpt": "PHP", + ".pls": "PLSQL", + ".pkb": "PLSQL", + ".pks": "PLSQL", + ".plb": "PLSQL", + ".plsql": "PLSQL", + ".sql": "SQLPL", + ".pan": "Pan", + ".psc": "Papyrus", + ".parrot": "Parrot", + ".pasm": "Parrot Assembly", + ".pir": "Parrot Internal Representation", + ".pas": "Pascal", + ".dfm": "Pascal", + ".dpr": "Pascal", + ".lpr": "Pascal", + ".pp": "Puppet", + ".pl": "Prolog", + ".al": "Perl", + ".cgi": "Shell", + ".perl": "Perl", + ".ph": "Perl", + ".plx": "Perl", + ".pm": "Perl6", + ".pod": "Pod", + ".psgi": "Perl", + ".t": "Turing", + ".6pl": "Perl6", + ".6pm": "Perl6", + ".nqp": "Perl6", + ".p6": "Perl6", + ".p6l": "Perl6", + ".p6m": "Perl6", + ".pl6": "Perl6", + ".pm6": "Perl6", + ".pig": "PigLatin", + ".pike": "Pike", + ".pmod": "Pike", + ".pogo": "PogoScript", + ".ps": "PostScript", + ".eps": "PostScript", + ".ps1": "PowerShell", + ".psd1": "PowerShell", + ".psm1": "PowerShell", + ".pde": "Processing", + ".prolog": "Prolog", + ".spin": "Propeller Spin", + ".proto": "Protocol Buffer", + ".pub": "Public Key", + ".pd": "Pure Data", + ".pb": "PureBasic", + ".pbi": "PureBasic", + ".purs": "PureScript", + ".py": "Python", + ".gyp": "Python", + ".lmi": "Python", + ".pyde": "Python", + ".pyp": "Python", + ".pyt": "Python", + ".pyw": "Python", + ".tac": "Python", + ".wsgi": "Python", + ".xpy": "Python", + ".pytb": "Python traceback", + ".qml": "QML", + ".qbs": "QML", + ".pri": "QMake", + ".r": "Rebol", + ".rd": "R", + ".rsx": "R", + ".raml": "RAML", + ".rdoc": "RDoc", + ".rbbas": "REALbasic", + ".rbfrm": "REALbasic", + ".rbmnu": "REALbasic", + ".rbres": "REALbasic", + ".rbtbar": "REALbasic", + ".rbuistate": "REALbasic", + ".rhtml": "RHTML", + ".rmd": "RMarkdown", + ".rkt": "Racket", + ".rktd": "Racket", + ".rktl": "Racket", + ".scrbl": "Racket", + ".rl": "Ragel in Ruby Host", + ".raw": "Raw token data", + ".reb": "Rebol", + ".r2": "Rebol", + ".r3": "Rebol", + ".rebol": "Rebol", + ".red": "Red", + ".reds": "Red", + ".cw": "Redcode", + ".rs": "Rust", + ".rsh": "RenderScript", + ".robot": "RobotFramework", + ".rg": "Rouge", + ".rb": "Ruby", + ".builder": "Ruby", + ".gemspec": "Ruby", + ".god": "Ruby", + ".irbrc": "Ruby", + ".jbuilder": "Ruby", + ".mspec": "Ruby", + ".pluginspec": "XML", + ".podspec": "Ruby", + ".rabl": "Ruby", + ".rake": "Ruby", + ".rbuild": "Ruby", + ".rbw": "Ruby", + ".rbx": "Ruby", + ".ru": "Ruby", + ".ruby": "Ruby", + ".thor": "Ruby", + ".watchr": "Ruby", + ".sas": "SAS", + ".scss": "SCSS", + ".smt2": "SMT", + ".smt": "SMT", + ".sparql": "SPARQL", + ".rq": "SPARQL", + ".sqf": "SQF", + ".hqf": "SQF", + ".cql": "SQL", + ".ddl": "SQL", + ".prc": "SQL", + ".tab": "SQL", + ".udf": "SQL", + ".viw": "SQL", + ".db2": "SQLPL", + ".ston": "STON", + ".svg": "SVG", + ".sage": "Sage", + ".sagews": "Sage", + ".sls": "Scheme", + ".sass": "Sass", + ".scala": "Scala", + ".sbt": "Scala", + ".sc": "SuperCollider", + ".scaml": "Scaml", + ".scm": "Scheme", + ".sld": "Scheme", + ".sps": "Scheme", + ".ss": "Scheme", + ".sci": "Scilab", + ".sce": "Scilab", + ".self": "Self", + ".sh": "Shell", + ".bash": "Shell", + ".bats": "Shell", + ".command": "Shell", + ".ksh": "Shell", + ".tmux": "Shell", + ".tool": "Shell", + ".zsh": "Shell", + ".sh-session": "ShellSession", + ".shen": "Shen", + ".sl": "Slash", + ".slim": "Slim", + ".smali": "Smali", + ".tpl": "Smarty", + ".sp": "SourcePawn", + ".sma": "SourcePawn", + ".nut": "Squirrel", + ".fun": "Standard ML", + ".sig": "Standard ML", + ".sml": "Standard ML", + ".do": "Stata", + ".ado": "Stata", + ".doh": "Stata", + ".ihlp": "Stata", + ".mata": "Stata", + ".matah": "Stata", + ".sthlp": "Stata", + ".styl": "Stylus", + ".scd": "SuperCollider", + ".swift": "Swift", + ".sv": "SystemVerilog", + ".svh": "SystemVerilog", + ".vh": "SystemVerilog", + ".toml": "TOML", + ".txl": "TXL", + ".tcl": "Tcl", + ".adp": "Tcl", + ".tm": "Tcl", + ".tcsh": "Tcsh", + ".csh": "Tcsh", + ".tex": "TeX", + ".aux": "TeX", + ".bbx": "TeX", + ".bib": "TeX", + ".cbx": "TeX", + ".dtx": "TeX", + ".ins": "TeX", + ".lbx": "TeX", + ".ltx": "TeX", + ".mkii": "TeX", + ".mkiv": "TeX", + ".mkvi": "TeX", + ".sty": "TeX", + ".toc": "TeX", + ".tea": "Tea", + ".txt": "Text", + ".textile": "Textile", + ".thrift": "Thrift", + ".tu": "Turing", + ".ttl": "Turtle", + ".twig": "Twig", + ".ts": "XML", + ".upc": "Unified Parallel C", + ".anim": "Unity3D Asset", + ".asset": "Unity3D Asset", + ".mat": "Unity3D Asset", + ".meta": "Unity3D Asset", + ".prefab": "Unity3D Asset", + ".unity": "Unity3D Asset", + ".uc": "UnrealScript", + ".vcl": "VCL", + ".vhdl": "VHDL", + ".vhd": "VHDL", + ".vhf": "VHDL", + ".vhi": "VHDL", + ".vho": "VHDL", + ".vhs": "VHDL", + ".vht": "VHDL", + ".vhw": "VHDL", + ".vala": "Vala", + ".vapi": "Vala", + ".veo": "Verilog", + ".vim": "VimL", + ".vb": "Visual Basic", + ".bas": "Visual Basic", + ".frm": "Visual Basic", + ".frx": "Visual Basic", + ".vba": "Visual Basic", + ".vbhtml": "Visual Basic", + ".vbs": "Visual Basic", + ".volt": "Volt", + ".vue": "Vue", + ".owl": "Web Ontology Language", + ".webidl": "WebIDL", + ".xc": "XC", + ".xml": "XML", + ".ant": "XML", + ".axml": "XML", + ".ccxml": "XML", + ".clixml": "XML", + ".cproject": "XML", + ".csproj": "XML", + ".ct": "XML", + ".dita": "XML", + ".ditamap": "XML", + ".ditaval": "XML", + ".dll.config": "XML", + ".filters": "XML", + ".fsproj": "XML", + ".fxml": "XML", + ".glade": "XML", + ".grxml": "XML", + ".iml": "XML", + ".ivy": "XML", + ".jelly": "XML", + ".kml": "XML", + ".launch": "XML", + ".mdpolicy": "XML", + ".mxml": "XML", + ".nproj": "XML", + ".nuspec": "XML", + ".odd": "XML", + ".osm": "XML", + ".plist": "XML", + ".ps1xml": "XML", + ".psc1": "XML", + ".pt": "XML", + ".rdf": "XML", + ".rss": "XML", + ".scxml": "XML", + ".srdf": "XML", + ".storyboard": "XML", + ".sttheme": "XML", + ".sublime-snippet": "XML", + ".targets": "XML", + ".tmcommand": "XML", + ".tml": "XML", + ".tmlanguage": "XML", + ".tmpreferences": "XML", + ".tmsnippet": "XML", + ".tmtheme": "XML", + ".ui": "XML", + ".urdf": "XML", + ".vbproj": "XML", + ".vcxproj": "XML", + ".vxml": "XML", + ".wsdl": "XML", + ".wsf": "XML", + ".wxi": "XML", + ".wxl": "XML", + ".wxs": "XML", + ".x3d": "XML", + ".xacro": "XML", + ".xaml": "XML", + ".xib": "XML", + ".xlf": "XML", + ".xliff": "XML", + ".xmi": "XML", + ".xml.dist": "XML", + ".xsd": "XML", + ".xul": "XML", + ".zcml": "XML", + ".xsp-config": "XPages", + ".xsp.metadata": "XPages", + ".xpl": "XProc", + ".xproc": "XProc", + ".xquery": "XQuery", + ".xq": "XQuery", + ".xql": "XQuery", + ".xqm": "XQuery", + ".xqy": "XQuery", + ".xs": "XS", + ".xslt": "XSLT", + ".xsl": "XSLT", + ".xojo_code": "Xojo", + ".xojo_menu": "Xojo", + ".xojo_report": "Xojo", + ".xojo_script": "Xojo", + ".xojo_toolbar": "Xojo", + ".xojo_window": "Xojo", + ".xtend": "Xtend", + ".yml": "YAML", + ".reek": "YAML", + ".rviz": "YAML", + ".syntax": "YAML", + ".yaml": "YAML", + ".yaml-tmlanguage": "YAML", + ".y": "Yacc", + ".yacc": "Yacc", + ".yy": "Yacc", + ".zep": "Zephir", + ".zimpl": "Zimpl", + ".zmpl": "Zimpl", + ".zpl": "Zimpl", + ".desktop": "desktop", + ".desktop.in": "desktop", + ".ec": "eC", + ".eh": "eC", + ".edn": "edn", + ".fish": "fish", + ".mu": "mupad", + ".nc": "nesC", + ".ooc": "ooc", + ".rst": "reStructuredText", + ".rest": "reStructuredText", + ".wisp": "wisp", + ".prg": "xBase", + ".prw": "xBase" +} \ No newline at end of file diff --git a/x-pack/plugins/code/server/utils/index_stats_aggregator.ts b/x-pack/plugins/code/server/utils/index_stats_aggregator.ts new file mode 100644 index 000000000000000..80622a19b1c33d2 --- /dev/null +++ b/x-pack/plugins/code/server/utils/index_stats_aggregator.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 { IndexStats, IndexStatsKey } from '../../model'; + +export function aggregateIndexStats(stats: IndexStats[]): IndexStats { + const res = new Map() + .set(IndexStatsKey.File, 0) + .set(IndexStatsKey.Symbol, 0) + .set(IndexStatsKey.Reference, 0); + stats.forEach((s: IndexStats) => { + s.forEach((value: number, key: IndexStatsKey) => { + res.set(key, res.get(key)! + value); + }); + }); + return res; +} diff --git a/x-pack/plugins/code/server/utils/log_factory.ts b/x-pack/plugins/code/server/utils/log_factory.ts new file mode 100644 index 000000000000000..c43e0c2d18ed1ed --- /dev/null +++ b/x-pack/plugins/code/server/utils/log_factory.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 { Logger } from '../log'; + +export interface LoggerFactory { + getLogger(tags: string[]): Logger; +} diff --git a/x-pack/plugins/code/server/utils/server_logger_factory.ts b/x-pack/plugins/code/server/utils/server_logger_factory.ts new file mode 100644 index 000000000000000..ad45b0d5be564aa --- /dev/null +++ b/x-pack/plugins/code/server/utils/server_logger_factory.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. + */ + +import * as Hapi from 'hapi'; +import { Logger } from '../log'; +import { LoggerFactory } from './log_factory'; + +export class ServerLoggerFactory implements LoggerFactory { + constructor(private readonly server: Hapi.Server) {} + + public getLogger(tags: string[]): Logger { + return new Logger(this.server, tags); + } +} diff --git a/x-pack/plugins/code/server/utils/timeout.ts b/x-pack/plugins/code/server/utils/timeout.ts new file mode 100644 index 000000000000000..4892e082ee4bd91 --- /dev/null +++ b/x-pack/plugins/code/server/utils/timeout.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 Boom from 'boom'; + +export function promiseTimeout(ms: number, promise: Promise): Promise { + const boom = Boom.gatewayTimeout('Timed out in ' + ms + 'ms.'); + // @ts-ignore + boom.isTimeout = true; + + if (ms > 0) { + // Create a promise that rejects in milliseconds + const timeout = new Promise((resolve, reject) => { + const id = setTimeout(() => { + clearTimeout(id); + reject(boom); + }, ms); + }); + + // Returns a race between our timeout and the passed in promise + return Promise.race([promise, timeout]); + } else { + return Promise.reject(boom); + } +} diff --git a/x-pack/plugins/code/server/utils/with_request.ts b/x-pack/plugins/code/server/utils/with_request.ts new file mode 100644 index 000000000000000..fe049d044d4df18 --- /dev/null +++ b/x-pack/plugins/code/server/utils/with_request.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 { Request } from 'hapi'; +import { AnyObject } from '../lib/esqueue'; + +export class WithRequest { + public readonly callCluster: (endpoint: string, clientOptions?: AnyObject) => Promise; + + constructor(readonly req: Request) { + const cluster = req.server.plugins.elasticsearch.getCluster('data'); + + // @ts-ignore + const securityPlugin = req.server.plugins.security; + if (securityPlugin) { + const useRbac = securityPlugin.authorization.mode.useRbacForRequest(req); + if (useRbac) { + this.callCluster = cluster.callWithInternalUser; + return; + } + } + this.callCluster = cluster.callWithRequest.bind(null, req); + } +} diff --git a/x-pack/plugins/code/tasks/nodegit_info.ts b/x-pack/plugins/code/tasks/nodegit_info.ts new file mode 100644 index 000000000000000..223177390a1d3f2 --- /dev/null +++ b/x-pack/plugins/code/tasks/nodegit_info.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. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +// @ts-ignore +import binary_info from '@elastic/nodegit/dist/utils/binary_info'; + +export function binaryInfo(platform: string, arch: string) { + const info = binary_info(platform, arch); + const downloadUrl = info.hosted_tarball; + const packageName = info.package_name; + return { + downloadUrl, + packageName, + }; +} diff --git a/x-pack/plugins/code/webpackShims/stats-lite.d.ts b/x-pack/plugins/code/webpackShims/stats-lite.d.ts new file mode 100644 index 000000000000000..60bee75396767ec --- /dev/null +++ b/x-pack/plugins/code/webpackShims/stats-lite.d.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 function histogram(vals: any, bins: any): any; +export function mean(vals: any): any; +export function median(vals: any): any; +export function mode(vals: any): any; +export function numbers(vals: any): any; +export function percentile(vals: any, ptile: any): any; +export function populationStdev(vals: any): any; +export function populationVariance(vals: any): any; +export function sampleStdev(vals: any): any; +export function sampleVariance(vals: any): any; +export function stdev(vals: any): any; +export function sum(vals: any): any; +export function variance(vals: any): any; diff --git a/x-pack/tasks/test.js b/x-pack/tasks/test.js index 5d6f4a0ab44636d..4041eb29f0154b5 100644 --- a/x-pack/tasks/test.js +++ b/x-pack/tasks/test.js @@ -28,9 +28,10 @@ export default (gulp, { mocha }) => { gulp.task('testserver', () => { const globs = [ 'common/**/__tests__/**/*.js', + 'common/**/__tests__/**/*.ts', 'server/**/__tests__/**/*.js', + 'server/**/__tests__/**/*.ts', ].concat(forPluginServerTests()); - return gulp.src(globs, { read: false }) .pipe(mocha(MOCHA_OPTIONS)); }); diff --git a/x-pack/test/api_integration/apis/code/feature_controls.ts b/x-pack/test/api_integration/apis/code/feature_controls.ts new file mode 100644 index 000000000000000..894b4eff9323a73 --- /dev/null +++ b/x-pack/test/api_integration/apis/code/feature_controls.ts @@ -0,0 +1,365 @@ +/* + * 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 expect from '@kbn/expect'; +import { SecurityService, SpacesService } from '../../../common/services'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function featureControlsTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertestWithoutAuth'); + const security: SecurityService = getService('security'); + const spaces: SpacesService = getService('spaces'); + const log = getService('log'); + + const expect404 = (result: any) => { + expect(result.error).to.be(undefined); + expect(result.response).not.to.be(undefined); + expect(result.response).to.have.property('statusCode', 404); + }; + + const expect200 = (result: any) => { + expect(result.error).to.be(undefined); + expect(result.response).not.to.be(undefined); + expect(result.response).to.have.property('statusCode', 200); + }; + + const endpoints = [ + { + // List all repositories. + url: `/api/code/repos`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + // Get one repository. + url: `/api/code/repo/github.com/Microsoft/TypeScript-Node-Starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + // Get the status of one repository. + url: `/api/code/repo/status/github.com/Microsoft/TypeScript-Node-Starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + // Get all language server installation status. + url: `/api/code/install`, + expectForbidden: expect404, + expectResponse: expect200, + }, + // Search and suggestion APIs. + { + url: `/api/code/search/repo?q=starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + url: `/api/code/suggestions/repo?q=starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + url: `/api/code/search/doc?q=starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + url: `/api/code/suggestions/doc?q=starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + url: `/api/code/search/symbol?q=starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + { + url: `/api/code/suggestions/symbol?q=starter`, + expectForbidden: expect404, + expectResponse: expect200, + }, + ]; + + async function executeRequest( + endpoint: string, + username: string, + password: string, + spaceId?: string + ) { + const basePath = spaceId ? `/s/${spaceId}` : ''; + + return await supertest + .get(`${basePath}${endpoint}`) + .auth(username, password) + .set('kbn-xsrf', 'foo') + .then((response: any) => ({ error: undefined, response })) + .catch((error: any) => ({ error, response: undefined })); + } + + async function executeRequests( + username: string, + password: string, + spaceId: string, + expectation: 'forbidden' | 'response' + ) { + for (const endpoint of endpoints) { + log.debug(`hitting ${endpoint}`); + const result = await executeRequest(endpoint.url, username, password, spaceId); + if (expectation === 'forbidden') { + endpoint.expectForbidden(result); + } else { + endpoint.expectResponse(result); + } + } + } + + describe('feature controls', () => { + const kibanaUsername = 'kibana_user'; + const kibanaUserRoleName = 'kibana_user'; + + const kibanaUserPassword = `${kibanaUsername}-password`; + + before(async () => { + // Import a repository first + await security.user.create(kibanaUsername, { + password: kibanaUserPassword, + roles: [kibanaUserRoleName], + full_name: 'a kibana user', + }); + + await supertest + .post(`/api/code/repo`) + .auth(kibanaUsername, kibanaUserPassword) + .set('kbn-xsrf', 'foo') + .send({ url: 'https://github.com/Microsoft/TypeScript-Node-Starter' }) + .expect(200); + }); + + after(async () => { + // Delete the repository + await supertest + .delete(`/api/code/repo/github.com/Microsoft/TypeScript-Node-Starter`) + .auth(kibanaUsername, kibanaUserPassword) + .set('kbn-xsrf', 'foo') + .expect(200); + + await security.user.delete(kibanaUsername); + }); + + it(`Non admin Code user cannot execute delete without all permission`, async () => { + const username = 'logstash_read'; + const roleName = 'logstash_read'; + const password = `${username}-password`; + try { + await security.role.create(roleName, { + kibana: [ + { + feature: { + // Grant read only permission to Code app as an non-admin user. + code: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + await supertest + .delete(`/api/code/repo/github.com/Microsoft/TypeScript-Node-Starter`) + .auth(username, password) + .set('kbn-xsrf', 'foo') + .expect(404); + } finally { + await security.role.delete(roleName); + await security.user.delete(username); + } + }); + + it(`Admin Code user can execute clone/delete with all permission`, async () => { + const username = 'logstash_read'; + const roleName = 'logstash_read'; + const password = `${username}-password`; + try { + await security.role.create(roleName, { + kibana: [ + { + feature: { + // Grant all permission to Code app as an admin user. + code: ['all'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + // Clone repository + await supertest + .post(`/api/code/repo`) + .auth(username, password) + .set('kbn-xsrf', 'foo') + .send({ url: 'https://github.com/elastic/TypeScript-Node-Starter' }) + .expect(200); + + // Delete repository + await supertest + .delete(`/api/code/repo/github.com/elastic/TypeScript-Node-Starter`) + .auth(username, password) + .set('kbn-xsrf', 'foo') + .expect(200); + } finally { + await security.role.delete(roleName); + await security.user.delete(username); + } + }); + + it(`APIs can't be accessed by .code-* read privileges role`, async () => { + const username = 'logstash_read'; + const roleName = 'logstash_read'; + const password = `${username}-password`; + try { + await security.role.create(roleName, {}); + + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + await executeRequests(username, password, '', 'forbidden'); + } finally { + await security.role.delete(roleName); + await security.user.delete(username); + } + }); + + it(`APIs can be accessed global all with .code-* read privileges role`, async () => { + const username = 'global_all'; + const roleName = 'global_all'; + const password = `${username}-password`; + try { + await security.role.create(roleName, { + kibana: [ + { + base: ['all'], + spaces: ['*'], + }, + ], + }); + + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + await executeRequests(username, password, '', 'response'); + } finally { + await security.role.delete(roleName); + await security.user.delete(username); + } + }); + + it(`APIs can't be accessed by dashboard all with .code-* read privileges role`, async () => { + const username = 'dashboard_all'; + const roleName = 'dashboard_all'; + const password = `${username}-password`; + try { + await security.role.create(roleName, { + kibana: [ + { + feature: { + dashboard: ['all'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + await executeRequests(username, password, '', 'forbidden'); + } finally { + await security.role.delete(roleName); + await security.user.delete(username); + } + }); + + describe('spaces', () => { + // the following tests create a user_1 which has Code read access to space_1 and dashboard all access to space_2 + const space1Id = 'space_1'; + const space2Id = 'space_2'; + + const roleName = 'user_1'; + const username = 'user_1'; + const password = 'user_1-password'; + + before(async () => { + await spaces.create({ + id: space1Id, + name: space1Id, + disabledFeatures: [], + }); + await spaces.create({ + id: space2Id, + name: space2Id, + disabledFeatures: [], + }); + await security.role.create(roleName, { + kibana: [ + { + feature: { + code: ['read'], + }, + spaces: [space1Id], + }, + { + feature: { + dashboard: ['all'], + }, + spaces: [space2Id], + }, + ], + }); + await security.user.create(username, { + password, + roles: [roleName], + }); + }); + + after(async () => { + await spaces.delete(space1Id); + await spaces.delete(space2Id); + await security.role.delete(roleName); + await security.user.delete(username); + }); + + it('user_1 can access APIs in space_1', async () => { + await executeRequests(username, password, space1Id, 'response'); + }); + + it(`user_1 cannot access APIs in space_2`, async () => { + await executeRequests(username, password, space2Id, 'forbidden'); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/code/index.ts b/x-pack/test/api_integration/apis/code/index.ts new file mode 100644 index 000000000000000..b42b5965b0f46d5 --- /dev/null +++ b/x-pack/test/api_integration/apis/code/index.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. + */ + +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function apmApiIntegrationTests({ + loadTestFile, +}: KibanaFunctionalTestDefaultProviders) { + describe('Code', () => { + loadTestFile(require.resolve('./feature_controls')); + }); +} diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 5c33215392a3f54..210402904c084d8 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -21,5 +21,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./uptime')); loadTestFile(require.resolve('./maps')); loadTestFile(require.resolve('./apm')); + loadTestFile(require.resolve('./code')); }); } diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 2282c93eae36e0e..def63e9edbe94a5 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -506,6 +506,39 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `ui:${version}:apm/show`, ], }, + code: { + all: [ + 'login:', + `version:${version}`, + `api:${version}:code_user`, + `api:${version}:code_admin`, + `app:${version}:code`, + `app:${version}:kibana`, + `ui:${version}:navLinks/code`, + `saved_object:${version}:config/bulk_get`, + `saved_object:${version}:config/get`, + `saved_object:${version}:config/find`, + `ui:${version}:savedObjectsManagement/config/read`, + `ui:${version}:code/show`, + `ui:${version}:code/user`, + `ui:${version}:code/admin`, + 'allHack:', + ], + read: [ + 'login:', + `version:${version}`, + `api:${version}:code_user`, + `app:${version}:code`, + `app:${version}:kibana`, + `ui:${version}:navLinks/code`, + `saved_object:${version}:config/bulk_get`, + `saved_object:${version}:config/get`, + `saved_object:${version}:config/find`, + `ui:${version}:savedObjectsManagement/config/read`, + `ui:${version}:code/show`, + `ui:${version}:code/user`, + ], + }, maps: { all: [ 'login:', @@ -865,6 +898,13 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `ui:${version}:catalogue/apm`, `ui:${version}:navLinks/apm`, `ui:${version}:apm/show`, + `api:${version}:code_user`, + `api:${version}:code_admin`, + `app:${version}:code`, + `ui:${version}:navLinks/code`, + `ui:${version}:code/show`, + `ui:${version}:code/user`, + `ui:${version}:code/admin`, `app:${version}:maps`, `ui:${version}:catalogue/maps`, `ui:${version}:navLinks/maps`, @@ -985,6 +1025,11 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `ui:${version}:catalogue/apm`, `ui:${version}:navLinks/apm`, `ui:${version}:apm/show`, + `api:${version}:code_user`, + `app:${version}:code`, + `ui:${version}:navLinks/code`, + `ui:${version}:code/show`, + `ui:${version}:code/user`, `app:${version}:maps`, `ui:${version}:catalogue/maps`, `ui:${version}:navLinks/maps`, @@ -1144,6 +1189,13 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `ui:${version}:catalogue/apm`, `ui:${version}:navLinks/apm`, `ui:${version}:apm/show`, + `api:${version}:code_user`, + `api:${version}:code_admin`, + `app:${version}:code`, + `ui:${version}:navLinks/code`, + `ui:${version}:code/show`, + `ui:${version}:code/user`, + `ui:${version}:code/admin`, `app:${version}:maps`, `ui:${version}:catalogue/maps`, `ui:${version}:navLinks/maps`, @@ -1264,6 +1316,11 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `ui:${version}:catalogue/apm`, `ui:${version}:navLinks/apm`, `ui:${version}:apm/show`, + `api:${version}:code_user`, + `app:${version}:code`, + `ui:${version}:navLinks/code`, + `ui:${version}:code/show`, + `ui:${version}:code/user`, `app:${version}:maps`, `ui:${version}:catalogue/maps`, `ui:${version}:navLinks/maps`, @@ -1337,6 +1394,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { logs: ['all', 'read'], uptime: ['all', 'read'], apm: ['all', 'read'], + code: ['all', 'read'], }, global: ['all', 'read'], space: ['all', 'read'], diff --git a/x-pack/test/api_integration/apis/xpack_main/features/features.ts b/x-pack/test/api_integration/apis/xpack_main/features/features.ts index 265ce5f475839c0..ddeff89513ad63f 100644 --- a/x-pack/test/api_integration/apis/xpack_main/features/features.ts +++ b/x-pack/test/api_integration/apis/xpack_main/features/features.ts @@ -37,6 +37,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { 'ml', 'apm', 'canvas', + 'code', 'infrastructure', 'logs', 'maps', diff --git a/x-pack/test/functional/apps/code/code_intelligence.ts b/x-pack/test/functional/apps/code/code_intelligence.ts new file mode 100644 index 000000000000000..0eaf1943cdf5e7c --- /dev/null +++ b/x-pack/test/functional/apps/code/code_intelligence.ts @@ -0,0 +1,259 @@ +/* + * 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 expect from '@kbn/expect'; +import { TestInvoker } from './lib/types'; + +// eslint-disable-next-line import/no-default-export +export default function codeIntelligenceFunctionalTests({ + getService, + getPageObjects, +}: TestInvoker) { + // const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const log = getService('log'); + const browser = getService('browser'); + const find = getService('find'); + const config = getService('config'); + const FIND_TIME = config.get('timeouts.find'); + const PageObjects = getPageObjects(['common', 'header', 'security', 'code', 'home']); + + describe('Code', () => { + describe('Code intelligence in source view page', () => { + const repositoryListSelector = 'codeRepositoryList codeRepositoryItem'; + + before(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Prepare a git repository for the test + await PageObjects.code.fillImportRepositoryUrlInputBox( + 'https://github.com/Microsoft/TypeScript-Node-Starter' + ); + // Click the import repository button. + await PageObjects.code.clickImportRepositoryButton(); + + await retry.tryForTime(300000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + expect(await repositoryItems[0].getVisibleText()).to.equal( + 'Microsoft/TypeScript-Node-Starter' + ); + + // Wait for the index to start. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexOngoing')).to.be(true); + }); + // Wait for the index to end. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexDone')).to.be(true); + }); + }); + }); + + after(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Clean up the imported repository + await PageObjects.code.clickDeleteRepositoryButton(); + await retry.try(async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(0); + }); + + await PageObjects.security.logout(); + }); + + beforeEach(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Enter the first repository from the admin page. + await testSubjects.click(repositoryListSelector); + }); + + it('Hover on a reference and jump to definition across file', async () => { + log.debug('Hover on a reference and jump to definition across file'); + + // Visit the /src/controllers/user.ts file + // Wait the file tree to be rendered and click the 'src' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src'); + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src/controllers')).to.be( + true + ); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src/controllers'); + // Then the 'controllers' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-File-src/controllers/user.ts')).to.be( + true + ); + }); + + await testSubjects.click('codeFileTreeNode-File-src/controllers/user.ts'); + // Then the 'user.ts' file on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeSourceViewer')).to.be(true); + }); + + // Hover on the 'UserModel' reference on line 5. + await retry.tryForTime(300000, async () => { + const spans = await find.allByCssSelector('.mtk31', FIND_TIME); + expect(spans.length).to.greaterThan(1); + const userModelSpan = spans[1]; + expect(await userModelSpan.getVisibleText()).to.equal('UserModel'); + await browser.moveMouseTo(userModelSpan); + // Expect the go to definition button show up eventually. + expect(await testSubjects.exists('codeGoToDefinitionButton')).to.be(true); + + await testSubjects.click('codeGoToDefinitionButton'); + await retry.tryForTime(5000, async () => { + const currentUrl: string = await browser.getCurrentUrl(); + log.info(`Jump to url: ${currentUrl}`); + // Expect to jump to src/models/User.ts file on line 5. + expect(currentUrl.indexOf('src/models/User.ts!L5:13')).to.greaterThan(0); + }); + + // it should goes back to controllers/user.ts + await browser.goBack(); + + await retry.try(async () => { + const $spans = await find.allByCssSelector('.mtk31', FIND_TIME); + expect($spans.length).to.greaterThan(1); + const $userModelSpan = $spans[1]; + expect(await $userModelSpan.getVisibleText()).to.equal('UserModel'); + }); + }); + }); + + it('Find references and jump to reference', async () => { + log.debug('Find references and jump to reference'); + + // Visit the /src/models/User.ts file + // Wait the file tree to be rendered and click the 'src' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src'); + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src/models')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src/models'); + // Then the 'models' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-File-src/models/User.ts')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-File-src/models/User.ts'); + // Then the 'User.ts' file on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeSourceViewer')).to.be(true); + }); + + // Hover on the 'UserModel' reference on line 5. + await retry.tryForTime(300000, async () => { + const spans = await find.allByCssSelector('.mtk31', FIND_TIME); + expect(spans.length).to.greaterThan(1); + const userModelSpan = spans[0]; + expect(await userModelSpan.getVisibleText()).to.equal('UserModel'); + await browser.moveMouseTo(userModelSpan); + // Expect the go to definition button show up eventually. + expect(await testSubjects.exists('codeFindReferenceButton')).to.be(true); + + await testSubjects.click('codeFindReferenceButton'); + await retry.tryForTime(5000, async () => { + // Expect the find references panel show up and having highlights. + const highlightSpans = await find.allByCssSelector('.codeSearch__highlight', FIND_TIME); + expect(highlightSpans.length).to.greaterThan(0); + const firstReference = highlightSpans[0]; + await firstReference.click(); + const currentUrl: string = await browser.getCurrentUrl(); + log.info(`Jump to url: ${currentUrl}`); + // Expect to jump to src/controllers/user.ts file on line 42. + expect(currentUrl.indexOf('src/controllers/user.ts!L42:0')).to.greaterThan(0); + }); + }); + }); + + it('Hover on a reference and jump to a different repository', async () => { + log.debug('Hover on a reference and jump to a different repository'); + + // Visit the /src/controllers/user.ts file + // Wait the file tree to be rendered and click the 'src' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src'); + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src/controllers')).to.be( + true + ); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src/controllers'); + // Then the 'controllers' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-File-src/controllers/user.ts')).to.be( + true + ); + }); + + await testSubjects.click('codeFileTreeNode-File-src/controllers/user.ts'); + // Then the 'user.ts' file on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeSourceViewer')).to.be(true); + }); + + // Hover on the 'async' reference on line 1. + await retry.tryForTime(300000, async () => { + const spans = await find.allByCssSelector('.mtk17', FIND_TIME); + expect(spans.length).to.greaterThan(1); + const asyncSpan = spans[1]; + expect(await asyncSpan.getVisibleText()).to.equal('async'); + await browser.moveMouseTo(asyncSpan); + // Expect the go to definition button show up eventually. + expect(await testSubjects.exists('codeGoToDefinitionButton')).to.be(true); + + await testSubjects.click('codeGoToDefinitionButton'); + // TODO: figure out why jenkins will fail the following test while locally it + // passes. + // await retry.tryForTime(5000, async () => { + // const currentUrl: string = await browser.getCurrentUrl(); + // log.error(`Jump to url: ${currentUrl}`); + // // Expect to jump to repository github.com/DefinitelyTyped/DefinitelyTyped. + // expect(currentUrl.indexOf('github.com/DefinitelyTyped/DefinitelyTyped')).to.greaterThan( + // 0 + // ); + // }); + + // it should goes back to controllers/user.ts + // await browser.goBack(); + + // await retry.try(async () => { + // const $spans = await find.allByCssSelector('.mtk31', FIND_TIME); + // expect($spans.length).to.greaterThan(1); + // const $userModelSpan = $spans[1]; + // expect(await $userModelSpan.getVisibleText()).to.equal('UserModel'); + // }); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/code/explore_repository.ts b/x-pack/test/functional/apps/code/explore_repository.ts new file mode 100644 index 000000000000000..f4269af83f8f85d --- /dev/null +++ b/x-pack/test/functional/apps/code/explore_repository.ts @@ -0,0 +1,210 @@ +/* + * 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 expect from '@kbn/expect'; +import { TestInvoker } from './lib/types'; + +// eslint-disable-next-line import/no-default-export +export default function exploreRepositoryFunctonalTests({ + getService, + getPageObjects, +}: TestInvoker) { + // const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const log = getService('log'); + const PageObjects = getPageObjects(['common', 'header', 'security', 'code', 'home']); + + describe('Code', () => { + describe('Explore a repository', () => { + const repositoryListSelector = 'codeRepositoryList codeRepositoryItem'; + + before(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Prepare a git repository for the test + await PageObjects.code.fillImportRepositoryUrlInputBox( + 'https://github.com/elastic/TypeScript-Node-Starter' + ); + // Click the import repository button. + await PageObjects.code.clickImportRepositoryButton(); + + await retry.tryForTime(10000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + expect(await repositoryItems[0].getVisibleText()).to.equal( + 'elastic/TypeScript-Node-Starter' + ); + }); + + // Wait for the index to start. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexOngoing')).to.be(true); + }); + // Wait for the index to end. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexDone')).to.be(true); + }); + }); + + after(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Clean up the imported repository + await PageObjects.code.clickDeleteRepositoryButton(); + await retry.try(async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(0); + }); + + await PageObjects.security.logout(); + }); + + beforeEach(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Enter the first repository from the admin page. + await testSubjects.click(repositoryListSelector); + }); + + it('tree should be loaded', async () => { + await retry.tryForTime(5000, async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-src-doc')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-test')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-views')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-File-package.json')).ok(); + }); + }); + + it('Click file/directory on the file tree', async () => { + log.debug('Click a file in the source tree'); + // Wait the file tree to be rendered and click the 'src' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src'); + + await retry.tryForTime(1000, async () => { + // should only open one folder at this time + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-src-open')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-src-doc-closed')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-test-closed')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-views-closed')).ok(); + }); + log.info('src folder opened'); + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-Directory-src/models')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-Directory-src/models'); + // Then the 'models' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileTreeNode-File-src/models/User.ts')).to.be(true); + }); + + await testSubjects.click('codeFileTreeNode-File-src/models/User.ts'); + // Then the 'User.ts' file on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeSourceViewer')).to.be(true); + }); + // open another folder + await testSubjects.click('codeFileTreeNode-Directory-src-doc'); + await retry.tryForTime(5000, async () => { + // now we should opened two folders + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-src-open')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-src-doc-open')).ok(); + }); + log.info('src-doc folder opened'); + + // click src again to close this folder + await testSubjects.click('codeFileTreeNode-Directory-src'); + + await retry.tryForTime(5000, async () => { + // should only close src folder + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-src-closed')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-src-doc-open')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-test-closed')).ok(); + expect(await testSubjects.exists('codeFileTreeNode-Directory-Icon-views-closed')).ok(); + }); + log.info('src folder closed'); + }); + + it('Click file/directory on the right panel', async () => { + log.debug('Click file/directory on the right panel'); + + // Wait the file tree to be rendered and click the 'src' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileExplorerNode-src')).to.be(true); + }); + + await testSubjects.click('codeFileExplorerNode-src'); + await retry.try(async () => { + expect(await testSubjects.exists('codeFileExplorerNode-models')).to.be(true); + }); + + await testSubjects.click('codeFileExplorerNode-models'); + // Then the 'models' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileExplorerNode-User.ts')).to.be(true); + }); + + await testSubjects.click('codeFileExplorerNode-User.ts'); + // Then the 'User.ts' file on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeSourceViewer')).to.be(true); + }); + }); + + it('Navigate source file via structure tree', async () => { + log.debug('Navigate source file via structure tree'); + // Wait the file tree to be rendered and click the 'src' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileExplorerNode-src')).to.be(true); + }); + + await testSubjects.click('codeFileExplorerNode-src'); + await retry.try(async () => { + expect(await testSubjects.exists('codeFileExplorerNode-models')).to.be(true); + }); + + await testSubjects.click('codeFileExplorerNode-models'); + // Then the 'models' folder on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeFileExplorerNode-User.ts')).to.be(true); + }); + + await testSubjects.click('codeFileExplorerNode-User.ts'); + // Then the 'User.ts' file on the file tree. + await retry.try(async () => { + expect(await testSubjects.exists('codeSourceViewer')).to.be(true); + expect(await testSubjects.exists('codeStructureTreeTab')).to.be(true); + }); + + // Click the structure tree tab + await testSubjects.click('codeStructureTreeTab'); + await retry.tryForTime(300000, async () => { + expect(await testSubjects.exists('codeStructureTreeNode-User')).to.be(true); + + await testSubjects.click('codeStructureTreeNode-User'); + await retry.tryForTime(5000, async () => { + const currentUrl: string = await browser.getCurrentUrl(); + log.info(`Jump to url: ${currentUrl}`); + expect(currentUrl.indexOf('src/models/User.ts!L92:6') > 0).to.be(true); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/code/history.ts b/x-pack/test/functional/apps/code/history.ts new file mode 100644 index 000000000000000..96beacb0d6f9422 --- /dev/null +++ b/x-pack/test/functional/apps/code/history.ts @@ -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 expect from '@kbn/expect'; +import { TestInvoker } from './lib/types'; + +// eslint-disable-next-line import/no-default-export +export default function manageRepositoriesFunctionalTests({ + getService, + getPageObjects, +}: TestInvoker) { + // const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const log = getService('log'); + const browser = getService('browser'); + const queryBar = getService('queryBar'); + const config = getService('config'); + const FIND_TIME = config.get('timeouts.find'); + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'header', 'security', 'code', 'home']); + + describe('Code', () => { + const repositoryListSelector = 'codeRepositoryList codeRepositoryItem'; + + describe('browser history can go back while exploring code app', () => { + let driver: any; + before(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + log.debug('Code test import repository'); + // Fill in the import repository input box with a valid git repository url. + await PageObjects.code.fillImportRepositoryUrlInputBox( + 'https://github.com/Microsoft/TypeScript-Node-Starter' + ); + // Click the import repository button. + await PageObjects.code.clickImportRepositoryButton(); + + const webDriver = await getService('__webdriver__').init(); + driver = webDriver.driver; + }); + // after(async () => await esArchiver.unload('code')); + + after(async () => { + await PageObjects.security.logout(); + }); + + it('from admin page to source view page can go back and forward', async () => { + await retry.tryForTime(300000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + expect(await repositoryItems[0].getVisibleText()).to.equal( + 'Microsoft/TypeScript-Node-Starter' + ); + }); + + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexDone')).to.be(true); + + log.debug('it goes to Microsoft/TypeScript-Node-Starter project page'); + await testSubjects.click('adminLinkToTypeScript-Node-Starter'); + await retry.try(async () => { + expect(await testSubjects.exists('codeStructureTreeTab')).to.be(true); + }); + + // can go back to admin page + await browser.goBack(); + + await retry.tryForTime(300000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + expect(await repositoryItems[0].getVisibleText()).to.equal( + 'Microsoft/TypeScript-Node-Starter' + ); + }); + + // can go forward to source view page + await driver.navigate().forward(); + + await retry.try(async () => { + expect(await testSubjects.exists('codeStructureTreeTab')).to.be(true); + }); + }); + }); + + it('from source view page to search page can go back and forward', async () => { + log.debug('it search a symbol'); + await queryBar.setQuery('user'); + await queryBar.submitQuery(); + await retry.try(async () => { + const searchResultListSelector = 'codeSearchResultList codeSearchResultFileItem'; + const results = await testSubjects.findAll(searchResultListSelector); + expect(results).to.be.ok(); + }); + log.debug('it goes back to project page'); + await browser.goBack(); + retry.try(async () => { + expect(await testSubjects.exists('codeStructureTreeTab')).to.be(true); + }); + + await driver.navigate().forward(); + + await retry.try(async () => { + const searchResultListSelector = 'codeSearchResultList codeSearchResultFileItem'; + const results = await testSubjects.findAll(searchResultListSelector); + expect(results).to.be.ok(); + }); + }); + + it('in source view page file line number changed can go back and forward', async () => { + log.debug('it goes back after line number changed'); + const url = `${PageObjects.common.getHostPort()}/app/code#/github.com/Microsoft/TypeScript-Node-Starter`; + await browser.get(url); + const lineNumber = 20; + await testSubjects.click('codeFileTreeNode-File-package.json'); + const lineNumberElements = await find.allByCssSelector('.line-numbers'); + + await lineNumberElements[lineNumber].click(); + await retry.try(async () => { + const existence = await find.existsByCssSelector('.code-line-number-21', FIND_TIME); + expect(existence).to.be(true); + }); + + await browser.goBack(); + + await retry.try(async () => { + const existence = await find.existsByCssSelector('.code-line-number-21', FIND_TIME); + expect(existence).to.be(false); + }); + + await driver.navigate().forward(); + + await retry.try(async () => { + const existence = await find.existsByCssSelector('.code-line-number-21', FIND_TIME); + expect(existence).to.be(true); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/code/index.ts b/x-pack/test/functional/apps/code/index.ts new file mode 100644 index 000000000000000..76f07cce3efab67 --- /dev/null +++ b/x-pack/test/functional/apps/code/index.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 { TestInvoker } from './lib/types'; + +// eslint-disable-next-line import/no-default-export +export default function codeApp({ loadTestFile }: TestInvoker) { + describe('Code app', function codeAppTestSuite() { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./manage_repositories')); + loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./explore_repository')); + loadTestFile(require.resolve('./code_intelligence')); + loadTestFile(require.resolve('./with_security')); + loadTestFile(require.resolve('./history')); + }); +} diff --git a/x-pack/test/functional/apps/code/lib/types.ts b/x-pack/test/functional/apps/code/lib/types.ts new file mode 100644 index 000000000000000..2ed91406e5f48d2 --- /dev/null +++ b/x-pack/test/functional/apps/code/lib/types.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 type DescribeFn = (text: string, fn: () => void) => void; + +export interface TestDefinitionAuthentication { + username?: string; + password?: string; +} + +export type LoadTestFileFn = (path: string) => string; + +export type GetServiceFn = (service: string) => any; + +export type ReadConfigFileFn = (path: string) => any; + +export type GetPageObjectsFn = (pageObjects: string[]) => any; + +export interface TestInvoker { + getService: GetServiceFn; + getPageObjects: GetPageObjectsFn; + loadTestFile: LoadTestFileFn; + readConfigFile: ReadConfigFileFn; +} diff --git a/x-pack/test/functional/apps/code/manage_repositories.ts b/x-pack/test/functional/apps/code/manage_repositories.ts new file mode 100644 index 000000000000000..6917deede90da64 --- /dev/null +++ b/x-pack/test/functional/apps/code/manage_repositories.ts @@ -0,0 +1,110 @@ +/* + * 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 expect from '@kbn/expect'; +import { TestInvoker } from './lib/types'; + +// eslint-disable-next-line import/no-default-export +export default function manageRepositoriesFunctionalTests({ + getService, + getPageObjects, +}: TestInvoker) { + // const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const log = getService('log'); + const PageObjects = getPageObjects(['common', 'header', 'security', 'code', 'home']); + + describe('Code', () => { + const repositoryListSelector = 'codeRepositoryList codeRepositoryItem'; + + describe('Manage Repositories', () => { + before(async () => { + // Navigate to the code app. + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + // after(async () => await esArchiver.unload('code')); + + after(async () => { + await PageObjects.security.logout(); + }); + + it('import repository', async () => { + log.debug('Code test import repository'); + // Fill in the import repository input box with a valid git repository url. + await PageObjects.code.fillImportRepositoryUrlInputBox( + 'https://github.com/Microsoft/TypeScript-Node-Starter' + ); + // Click the import repository button. + await PageObjects.code.clickImportRepositoryButton(); + + await retry.tryForTime(300000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + expect(await repositoryItems[0].getVisibleText()).to.equal( + 'Microsoft/TypeScript-Node-Starter' + ); + }); + + // Wait for the index to start. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexOngoing')).to.be(true); + }); + // Wait for the index to end. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexDone')).to.be(true); + }); + }); + + it('delete repository', async () => { + log.debug('Code test delete repository'); + // Click the delete repository button. + await PageObjects.code.clickDeleteRepositoryButton(); + + await retry.try(async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(0); + }); + }); + + it('import a git:// repository', async () => { + log.debug('Code test import repository'); + // Fill in the import repository input box with a valid git repository url. + await PageObjects.code.fillImportRepositoryUrlInputBox( + 'git://github.com/Microsoft/TypeScript-Node-Starter' + ); + // Click the import repository button. + await PageObjects.code.clickImportRepositoryButton(); + + await retry.tryForTime(300000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + expect(await repositoryItems[0].getVisibleText()).to.equal( + 'Microsoft/TypeScript-Node-Starter' + ); + }); + + // Wait for the index to start. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexOngoing')).to.be(true); + }); + // Wait for the index to end. + await retry.try(async () => { + expect(await testSubjects.exists('repositoryIndexDone')).to.be(true); + }); + + // Delete the repository + await PageObjects.code.clickDeleteRepositoryButton(); + + await retry.try(async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(0); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/code/search.ts b/x-pack/test/functional/apps/code/search.ts new file mode 100644 index 000000000000000..02f18247d4b97c8 --- /dev/null +++ b/x-pack/test/functional/apps/code/search.ts @@ -0,0 +1,97 @@ +/* + * 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 expect from '@kbn/expect'; +import { TestInvoker } from './lib/types'; + +// eslint-disable-next-line import/no-default-export +export default function searchFunctonalTests({ getService, getPageObjects }: TestInvoker) { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const log = getService('log'); + const PageObjects = getPageObjects(['common', 'header', 'security', 'code', 'home']); + + describe('Code', () => { + const symbolTypeaheadListSelector = 'codeTypeaheadList-symbol codeTypeaheadItem'; + const searchResultListSelector = 'codeSearchResultList codeSearchResultFileItem'; + const languageFilterListSelector = 'codeSearchLanguageFilterList codeSearchLanguageFilterItem'; + + describe('Code Search', () => { + before(async () => { + await esArchiver.load('code'); + + // Navigate to the search page of the code app. + await PageObjects.common.navigateToApp('codeSearch'); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await PageObjects.security.logout(); + await esArchiver.unload('code'); + }); + + it('Trigger symbols in typeahead', async () => { + log.debug('Trigger symbols in typeahead'); + // Fill in the search query bar with a common prefix of symbols. + await PageObjects.code.fillSearchQuery('user'); + + await retry.tryForTime(5000, async () => { + const symbols = await testSubjects.findAll(symbolTypeaheadListSelector); + expect(symbols).to.have.length(2); + + expect(await symbols[0].getVisibleText()).to.equal('user'); + expect(await symbols[1].getVisibleText()).to.equal('passport.User'); + }); + }); + + it('Full text search', async () => { + log.debug('Full text search'); + // Fill in the search query bar with a common prefix of symbols. + await PageObjects.code.fillSearchQuery('user'); + await PageObjects.code.submitSearchQuery(); + + await retry.tryForTime(5000, async () => { + const results = await testSubjects.findAll(searchResultListSelector); + expect(results).to.have.length(3); + + // The third file has the most matches of the query, but is still ranked as + // the thrid because the the query matches the qname of the first 2 files. This + // is because qname got boosted more from search. + expect(await results[0].getVisibleText()).to.equal('src/controllers/user.ts'); + expect(await results[1].getVisibleText()).to.equal('src/models/User.ts'); + expect(await results[2].getVisibleText()).to.equal('src/config/passport.js'); + }); + }); + + it('Apply language filter', async () => { + log.debug('Apply language filter'); + // Fill in the search query bar with a common prefix of symbols. + await PageObjects.code.fillSearchQuery('user'); + await PageObjects.code.submitSearchQuery(); + + await retry.tryForTime(5000, async () => { + const langFilters = await testSubjects.findAll(languageFilterListSelector); + expect(langFilters).to.have.length(2); + + expect(await langFilters[0].getVisibleText()).to.equal('typescript\n2'); + expect(await langFilters[1].getVisibleText()).to.equal('javascript\n1'); + }); + + await retry.tryForTime(5000, async () => { + // click the first language filter item. + await testSubjects.click(languageFilterListSelector); + + const results = await testSubjects.findAll(searchResultListSelector); + expect(results).to.have.length(2); + + expect(await results[0].getVisibleText()).to.equal('src/controllers/user.ts'); + expect(await results[1].getVisibleText()).to.equal('src/models/User.ts'); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/code/with_security.ts b/x-pack/test/functional/apps/code/with_security.ts new file mode 100644 index 000000000000000..57d127fe0176253 --- /dev/null +++ b/x-pack/test/functional/apps/code/with_security.ts @@ -0,0 +1,148 @@ +/* + * 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 expect from '@kbn/expect'; +import { TestInvoker } from './lib/types'; + +// eslint-disable-next-line import/no-default-export +export default function testWithSecurity({ getService, getPageObjects }: TestInvoker) { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'header', 'security', 'code', 'home', 'settings']); + const dummyPassword = '123321'; + const codeAdmin = 'codeAdmin'; + const codeUser = 'codeUser'; + const repositoryListSelector = 'codeRepositoryList codeRepositoryItem'; + const manageButtonSelectors = ['indexRepositoryButton', 'deleteRepositoryButton']; + const log = getService('log'); + const security = getService('security'); + + describe('with security enabled:', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await security.role.create('global_code_all_role', { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + code: ['all'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create(codeAdmin, { + password: dummyPassword, + roles: ['global_code_all_role'], + full_name: 'code admin', + }); + + await security.role.create('global_code_read_role', { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + code: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create(codeUser, { + password: dummyPassword, + roles: ['global_code_read_role'], + full_name: 'code user', + }); + }); + + async function login(user: string) { + await PageObjects.security.logout(); + await PageObjects.security.login(user, dummyPassword); + await PageObjects.common.navigateToApp('code'); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + + it('codeAdmin should have an import button', async () => { + await login(codeAdmin); + await retry.tryForTime(5000, async () => { + const buttons = await testSubjects.findAll('importRepositoryButton'); + expect(buttons).to.have.length(1); + }); + }); + it('codeUser should not have that import button', async () => { + await login(codeUser); + await retry.tryForTime(5000, async () => { + const buttons = await testSubjects.findAll('importRepositoryButton'); + expect(buttons).to.have.length(0); + }); + }); + + it('only codeAdmin can manage repositories', async () => { + await login(codeAdmin); + await retry.tryForTime(5000, async () => { + const buttons = await testSubjects.findAll('importRepositoryButton'); + expect(buttons).to.have.length(1); + }); + await PageObjects.code.fillImportRepositoryUrlInputBox( + 'https://github.com/Microsoft/TypeScript-Node-Starter' + ); + // Click the import repository button. + await PageObjects.code.clickImportRepositoryButton(); + + await retry.tryForTime(300000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + for (const buttonSelector of manageButtonSelectors) { + const buttons = await testSubjects.findAll(buttonSelector); + expect(buttons).to.have.length(1); + log.debug(`button ${buttonSelector} found.`); + } + const importButton = await testSubjects.findAll('newProjectButton'); + expect(importButton).to.have.length(1); + log.debug(`button newProjectButton found.`); + }); + + await login(codeUser); + await retry.tryForTime(5000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + expect(repositoryItems).to.have.length(1); + for (const buttonSelector of manageButtonSelectors) { + const buttons = await testSubjects.findAll(buttonSelector); + expect(buttons).to.have.length(0); + } + const importButton = await testSubjects.findAll('newProjectButton'); + expect(importButton).to.have.length(0); + }); + }); + async function cleanProjects() { + // remove imported project + await login(codeAdmin); + await retry.tryForTime(30000, async () => { + const repositoryItems = await testSubjects.findAll(repositoryListSelector); + if (repositoryItems.length > 0) { + const deleteButton = await testSubjects.findAll('deleteRepositoryButton'); + if (deleteButton.length > 0) { + await PageObjects.code.clickDeleteRepositoryButton(); + } + } + expect(repositoryItems).to.have.length(0); + }); + } + + after(async () => { + await cleanProjects(); + await PageObjects.security.logout(); + await esArchiver.unload('code'); + }); + }); +} diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 5c542ea3e17a872..39cc0cfa1c678f5 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -24,6 +24,7 @@ import { GisPageProvider, StatusPagePageProvider, UpgradeAssistantProvider, + CodeHomePageProvider, RollupPageProvider, UptimePageProvider, } from './page_objects'; @@ -100,6 +101,7 @@ export default async function ({ readConfigFile }) { resolve(__dirname, './apps/status_page'), resolve(__dirname, './apps/timelion'), resolve(__dirname, './apps/upgrade_assistant'), + resolve(__dirname, './apps/code'), resolve(__dirname, './apps/visualize'), resolve(__dirname, './apps/uptime'), resolve(__dirname, './apps/saved_objects_management'), @@ -167,6 +169,7 @@ export default async function ({ readConfigFile }) { maps: GisPageProvider, statusPage: StatusPagePageProvider, upgradeAssistant: UpgradeAssistantProvider, + code: CodeHomePageProvider, uptime: UptimePageProvider, rollup: RollupPageProvider, }, @@ -188,6 +191,7 @@ export default async function ({ readConfigFile }) { '--xpack.xpack_main.telemetry.enabled=false', '--xpack.maps.showMapsInspectorAdapter=true', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions + '--xpack.code.security.enableGitCertCheck=false' // Disable git certificate check ], }, uiSettings: { @@ -239,6 +243,14 @@ export default async function ({ readConfigFile }) { pathname: '/app/canvas', hash: '/', }, + code: { + pathname: '/app/code', + hash: '/admin', + }, + codeSearch: { + pathname: '/app/code', + hash: '/search', + }, uptime: { pathname: '/app/uptime', }, diff --git a/x-pack/test/functional/es_archives/code/data.json b/x-pack/test/functional/es_archives/code/data.json new file mode 100644 index 000000000000000..747b9f5d1e3b51d --- /dev/null +++ b/x-pack/test/functional/es_archives/code/data.json @@ -0,0 +1,199 @@ +{ + "type": "_doc", + "value": { + "index": ".code-symbol-github.com-microsoft-typescript-node-starter-1", + "id": "F8yKqGgBtHAfrz5Oylh2", + "source": { + "qname": "user", + "symbolInformation": { + "name": "user", + "kind": 14, + "location": { + "uri": "git://github.com/Microsoft/TypeScript-Node-Starter/blob/master/src/config/passport.ts", + "range": { + "start": { + "line": 103, + "character": 16 + }, + "end": { + "line": 103, + "character": 38 + } + } + } + }, + "contents": [ + { + "language": "", + "value": "" + } + ], + "package": { + "name": "express-typescript-starter", + "repoUri": "https://github.com/Microsoft/TypeScript-Node-Starter", + "version": "0.1.0" + } + } + } +} + +{ + "type": "_doc", + "value": { + "index": ".code-symbol-github.com-microsoft-typescript-node-starter-1", + "type": "_doc", + "id": "JMyKqGgBtHAfrz5Oylh2", + "source": { + "qname": "passport.User", + "symbolInformation": { + "name": "User", + "kind": 13, + "location": { + "uri": "git://github.com/Microsoft/TypeScript-Node-Starter/blob/master/src/config/passport.ts", + "range": { + "start": { + "line": 7, + "character": 9 + }, + "end": { + "line": 7, + "character": 24 + } + } + }, + "containerName": "\"passport\"" + }, + "contents": [ + { + "language": "", + "value": "" + } + ], + "package": { + "name": "express-typescript-starter", + "repoUri": "https://github.com/Microsoft/TypeScript-Node-Starter", + "version": "0.1.0" + } + } + } +} + +{ + "type": "_doc", + "value": { + "index": ".code-document-github.com-microsoft-typescript-node-starter-1", + "type": "_doc", + "id": "bsyKqGgBtHAfrz5Oylh2", + "source": { + "repoUri": "github.com/Microsoft/TypeScript-Node-Starter", + "path": "src/controllers/user.ts", + "content": "import async from \"async\";\nimport crypto from \"crypto\";\nimport nodemailer from \"nodemailer\";\nimport passport from \"passport\";\nimport { default as User, UserModel, AuthToken } from \"../models/User\";\nimport { Request, Response, NextFunction } from \"express\";\nimport { IVerifyOptions } from \"passport-local\";\nimport { WriteError } from \"mongodb\";\nimport \"../config/passport\";\nconst request = require(\"express-validator\");\n\n\n/**\n * GET /login\n * Login page.\n */\nexport let getLogin = (req: Request, res: Response) => {\n if (req.user) {\n return res.redirect(\"/\");\n }\n res.render(\"account/login\", {\n title: \"Login\"\n });\n};\n\n/**\n * POST /login\n * Sign in using email and password.\n */\nexport let postLogin = (req: Request, res: Response, next: NextFunction) => {\n req.assert(\"email\", \"Email is not valid\").isEmail();\n req.assert(\"password\", \"Password cannot be blank\").notEmpty();\n req.sanitize(\"email\").normalizeEmail({ gmail_remove_dots: false });\n\n const errors = req.validationErrors();\n\n if (errors) {\n req.flash(\"errors\", errors);\n return res.redirect(\"/login\");\n }\n\n passport.authenticate(\"local\", (err: Error, user: UserModel, info: IVerifyOptions) => {\n if (err) { return next(err); }\n if (!user) {\n req.flash(\"errors\", info.message);\n return res.redirect(\"/login\");\n }\n req.logIn(user, (err) => {\n if (err) { return next(err); }\n req.flash(\"success\", { msg: \"Success! You are logged in.\" });\n res.redirect(req.session.returnTo || \"/\");\n });\n })(req, res, next);\n};\n\n/**\n * GET /logout\n * Log out.\n */\nexport let logout = (req: Request, res: Response) => {\n req.logout();\n res.redirect(\"/\");\n};\n\n/**\n * GET /signup\n * Signup page.\n */\nexport let getSignup = (req: Request, res: Response) => {\n if (req.user) {\n return res.redirect(\"/\");\n }\n res.render(\"account/signup\", {\n title: \"Create Account\"\n });\n};\n\n/**\n * POST /signup\n * Create a new local account.\n */\nexport let postSignup = (req: Request, res: Response, next: NextFunction) => {\n req.assert(\"email\", \"Email is not valid\").isEmail();\n req.assert(\"password\", \"Password must be at least 4 characters long\").len({ min: 4 });\n req.assert(\"confirmPassword\", \"Passwords do not match\").equals(req.body.password);\n req.sanitize(\"email\").normalizeEmail({ gmail_remove_dots: false });\n\n const errors = req.validationErrors();\n\n if (errors) {\n req.flash(\"errors\", errors);\n return res.redirect(\"/signup\");\n }\n\n const user = new User({\n email: req.body.email,\n password: req.body.password\n });\n\n User.findOne({ email: req.body.email }, (err, existingUser) => {\n if (err) { return next(err); }\n if (existingUser) {\n req.flash(\"errors\", { msg: \"Account with that email address already exists.\" });\n return res.redirect(\"/signup\");\n }\n user.save((err) => {\n if (err) { return next(err); }\n req.logIn(user, (err) => {\n if (err) {\n return next(err);\n }\n res.redirect(\"/\");\n });\n });\n });\n};\n\n/**\n * GET /account\n * Profile page.\n */\nexport let getAccount = (req: Request, res: Response) => {\n res.render(\"account/profile\", {\n title: \"Account Management\"\n });\n};\n\n/**\n * POST /account/profile\n * Update profile information.\n */\nexport let postUpdateProfile = (req: Request, res: Response, next: NextFunction) => {\n req.assert(\"email\", \"Please enter a valid email address.\").isEmail();\n req.sanitize(\"email\").normalizeEmail({ gmail_remove_dots: false });\n\n const errors = req.validationErrors();\n\n if (errors) {\n req.flash(\"errors\", errors);\n return res.redirect(\"/account\");\n }\n\n User.findById(req.user.id, (err, user: UserModel) => {\n if (err) { return next(err); }\n user.email = req.body.email || \"\";\n user.profile.name = req.body.name || \"\";\n user.profile.gender = req.body.gender || \"\";\n user.profile.location = req.body.location || \"\";\n user.profile.website = req.body.website || \"\";\n user.save((err: WriteError) => {\n if (err) {\n if (err.code === 11000) {\n req.flash(\"errors\", { msg: \"The email address you have entered is already associated with an account.\" });\n return res.redirect(\"/account\");\n }\n return next(err);\n }\n req.flash(\"success\", { msg: \"Profile information has been updated.\" });\n res.redirect(\"/account\");\n });\n });\n};\n\n/**\n * POST /account/password\n * Update current password.\n */\nexport let postUpdatePassword = (req: Request, res: Response, next: NextFunction) => {\n req.assert(\"password\", \"Password must be at least 4 characters long\").len({ min: 4 });\n req.assert(\"confirmPassword\", \"Passwords do not match\").equals(req.body.password);\n\n const errors = req.validationErrors();\n\n if (errors) {\n req.flash(\"errors\", errors);\n return res.redirect(\"/account\");\n }\n\n User.findById(req.user.id, (err, user: UserModel) => {\n if (err) { return next(err); }\n user.password = req.body.password;\n user.save((err: WriteError) => {\n if (err) { return next(err); }\n req.flash(\"success\", { msg: \"Password has been changed.\" });\n res.redirect(\"/account\");\n });\n });\n};\n\n/**\n * POST /account/delete\n * Delete user account.\n */\nexport let postDeleteAccount = (req: Request, res: Response, next: NextFunction) => {\n User.remove({ _id: req.user.id }, (err) => {\n if (err) { return next(err); }\n req.logout();\n req.flash(\"info\", { msg: \"Your account has been deleted.\" });\n res.redirect(\"/\");\n });\n};\n\n/**\n * GET /account/unlink/:provider\n * Unlink OAuth provider.\n */\nexport let getOauthUnlink = (req: Request, res: Response, next: NextFunction) => {\n const provider = req.params.provider;\n User.findById(req.user.id, (err, user: any) => {\n if (err) { return next(err); }\n user[provider] = undefined;\n user.tokens = user.tokens.filter((token: AuthToken) => token.kind !== provider);\n user.save((err: WriteError) => {\n if (err) { return next(err); }\n req.flash(\"info\", { msg: `${provider} account has been unlinked.` });\n res.redirect(\"/account\");\n });\n });\n};\n\n/**\n * GET /reset/:token\n * Reset Password page.\n */\nexport let getReset = (req: Request, res: Response, next: NextFunction) => {\n if (req.isAuthenticated()) {\n return res.redirect(\"/\");\n }\n User\n .findOne({ passwordResetToken: req.params.token })\n .where(\"passwordResetExpires\").gt(Date.now())\n .exec((err, user) => {\n if (err) { return next(err); }\n if (!user) {\n req.flash(\"errors\", { msg: \"Password reset token is invalid or has expired.\" });\n return res.redirect(\"/forgot\");\n }\n res.render(\"account/reset\", {\n title: \"Password Reset\"\n });\n });\n};\n\n/**\n * POST /reset/:token\n * Process the reset password request.\n */\nexport let postReset = (req: Request, res: Response, next: NextFunction) => {\n req.assert(\"password\", \"Password must be at least 4 characters long.\").len({ min: 4 });\n req.assert(\"confirm\", \"Passwords must match.\").equals(req.body.password);\n\n const errors = req.validationErrors();\n\n if (errors) {\n req.flash(\"errors\", errors);\n return res.redirect(\"back\");\n }\n\n async.waterfall([\n function resetPassword(done: Function) {\n User\n .findOne({ passwordResetToken: req.params.token })\n .where(\"passwordResetExpires\").gt(Date.now())\n .exec((err, user: any) => {\n if (err) { return next(err); }\n if (!user) {\n req.flash(\"errors\", { msg: \"Password reset token is invalid or has expired.\" });\n return res.redirect(\"back\");\n }\n user.password = req.body.password;\n user.passwordResetToken = undefined;\n user.passwordResetExpires = undefined;\n user.save((err: WriteError) => {\n if (err) { return next(err); }\n req.logIn(user, (err) => {\n done(err, user);\n });\n });\n });\n },\n function sendResetPasswordEmail(user: UserModel, done: Function) {\n const transporter = nodemailer.createTransport({\n service: \"SendGrid\",\n auth: {\n user: process.env.SENDGRID_USER,\n pass: process.env.SENDGRID_PASSWORD\n }\n });\n const mailOptions = {\n to: user.email,\n from: \"express-ts@starter.com\",\n subject: \"Your password has been changed\",\n text: `Hello,\\n\\nThis is a confirmation that the password for your account ${user.email} has just been changed.\\n`\n };\n transporter.sendMail(mailOptions, (err) => {\n req.flash(\"success\", { msg: \"Success! Your password has been changed.\" });\n done(err);\n });\n }\n ], (err) => {\n if (err) { return next(err); }\n res.redirect(\"/\");\n });\n};\n\n/**\n * GET /forgot\n * Forgot Password page.\n */\nexport let getForgot = (req: Request, res: Response) => {\n if (req.isAuthenticated()) {\n return res.redirect(\"/\");\n }\n res.render(\"account/forgot\", {\n title: \"Forgot Password\"\n });\n};\n\n/**\n * POST /forgot\n * Create a random token, then the send user an email with a reset link.\n */\nexport let postForgot = (req: Request, res: Response, next: NextFunction) => {\n req.assert(\"email\", \"Please enter a valid email address.\").isEmail();\n req.sanitize(\"email\").normalizeEmail({ gmail_remove_dots: false });\n\n const errors = req.validationErrors();\n\n if (errors) {\n req.flash(\"errors\", errors);\n return res.redirect(\"/forgot\");\n }\n\n async.waterfall([\n function createRandomToken(done: Function) {\n crypto.randomBytes(16, (err, buf) => {\n const token = buf.toString(\"hex\");\n done(err, token);\n });\n },\n function setRandomToken(token: AuthToken, done: Function) {\n User.findOne({ email: req.body.email }, (err, user: any) => {\n if (err) { return done(err); }\n if (!user) {\n req.flash(\"errors\", { msg: \"Account with that email address does not exist.\" });\n return res.redirect(\"/forgot\");\n }\n user.passwordResetToken = token;\n user.passwordResetExpires = Date.now() + 3600000; // 1 hour\n user.save((err: WriteError) => {\n done(err, token, user);\n });\n });\n },\n function sendForgotPasswordEmail(token: AuthToken, user: UserModel, done: Function) {\n const transporter = nodemailer.createTransport({\n service: \"SendGrid\",\n auth: {\n user: process.env.SENDGRID_USER,\n pass: process.env.SENDGRID_PASSWORD\n }\n });\n const mailOptions = {\n to: user.email,\n from: \"hackathon@starter.com\",\n subject: \"Reset your password on Hackathon Starter\",\n text: `You are receiving this email because you (or someone else) have requested the reset of the password for your account.\\n\\n\n Please click on the following link, or paste this into your browser to complete the process:\\n\\n\n http://${req.headers.host}/reset/${token}\\n\\n\n If you did not request this, please ignore this email and your password will remain unchanged.\\n`\n };\n transporter.sendMail(mailOptions, (err) => {\n req.flash(\"info\", { msg: `An e-mail has been sent to ${user.email} with further instructions.` });\n done(err);\n });\n }\n ], (err) => {\n if (err) { return next(err); }\n res.redirect(\"/forgot\");\n });\n};\n", + "language": "typescript", + "qnames": [ + "\"user\"", + "async", + "AuthToken", + "crypto", + "getAccount", + "getForgot", + "getLogin", + "getOauthUnlink", + "provider", + "getReset", + "getSignup", + "IVerifyOptions", + "logout", + "NextFunction", + "nodemailer", + "passport", + "postDeleteAccount", + "postForgot", + "createRandomToken", + "token", + "errors", + "sendForgotPasswordEmail", + "mailOptions", + "transporter", + "setRandomToken", + "postLogin", + "postReset", + "resetPassword", + "sendResetPasswordEmail", + "postSignup", + "user", + "postUpdatePassword", + "postUpdateProfile", + "request", + "Request", + "Response", + "User", + "UserModel", + "WriteError" + ] + } + } +} + +{ + "type": "_doc", + "value": { + "index": ".code-document-github.com-microsoft-typescript-node-starter-1", + "type": "_doc", + "id": "fcyKqGgBtHAfrz5Oylh2", + "source": { + "repoUri": "github.com/Microsoft/TypeScript-Node-Starter", + "path": "src/models/User.ts", + "content": "import bcrypt from \"bcrypt-nodejs\";\nimport crypto from \"crypto\";\nimport mongoose from \"mongoose\";\n\nexport type UserModel = mongoose.Document & {\n email: string,\n password: string,\n passwordResetToken: string,\n passwordResetExpires: Date,\n\n facebook: string,\n tokens: AuthToken[],\n\n profile: {\n name: string,\n gender: string,\n location: string,\n website: string,\n picture: string\n },\n\n comparePassword: comparePasswordFunction,\n gravatar: (size: number) => string\n};\n\ntype comparePasswordFunction = (candidatePassword: string, cb: (err: any, isMatch: any) => {}) => void;\n\nexport type AuthToken = {\n accessToken: string,\n kind: string\n};\n\nconst userSchema = new mongoose.Schema({\n email: { type: String, unique: true },\n password: String,\n passwordResetToken: String,\n passwordResetExpires: Date,\n\n facebook: String,\n twitter: String,\n google: String,\n tokens: Array,\n\n profile: {\n name: String,\n gender: String,\n location: String,\n website: String,\n picture: String\n }\n}, { timestamps: true });\n\n/**\n * Password hash middleware.\n */\nuserSchema.pre(\"save\", function save(next) {\n const user = this;\n if (!user.isModified(\"password\")) { return next(); }\n bcrypt.genSalt(10, (err, salt) => {\n if (err) { return next(err); }\n bcrypt.hash(user.password, salt, undefined, (err: mongoose.Error, hash) => {\n if (err) { return next(err); }\n user.password = hash;\n next();\n });\n });\n});\n\nconst comparePassword: comparePasswordFunction = function (candidatePassword, cb) {\n bcrypt.compare(candidatePassword, this.password, (err: mongoose.Error, isMatch: boolean) => {\n cb(err, isMatch);\n });\n};\n\nuserSchema.methods.comparePassword = comparePassword;\n\n/**\n * Helper method for getting user's gravatar.\n */\nuserSchema.methods.gravatar = function (size: number) {\n if (!size) {\n size = 200;\n }\n if (!this.email) {\n return `https://gravatar.com/avatar/?s=${size}&d=retro`;\n }\n const md5 = crypto.createHash(\"md5\").update(this.email).digest(\"hex\");\n return `https://gravatar.com/avatar/${md5}?s=${size}&d=retro`;\n};\n\n// export const User: UserType = mongoose.model('User', userSchema);\nconst User = mongoose.model(\"User\", userSchema);\nexport default User;\n", + "language": "typescript", + "qnames": [ + "\"User\"", + "AuthToken", + "bcrypt", + "comparePassword", + "comparePasswordFunction", + "crypto", + "gravatar", + "md5", + "mongoose", + "save", + "user", + "User", + "UserModel", + "userSchema" + ] + } + } +} + +{ + "type": "_doc", + "value": { + "index": ".code-document-github.com-microsoft-typescript-node-starter-1", + "type": "_doc", + "id": "JcyKqGgBtHAfrz5Oylh2", + "source": { + "repoUri": "github.com/Microsoft/TypeScript-Node-Starter", + "path": "src/config/passport.js", + "content": "import passport from \"passport\";\nimport request from \"request\";\nimport passportLocal from \"passport-local\";\nimport passportFacebook from \"passport-facebook\";\nimport _ from \"lodash\";\n\n// import { User, UserType } from '../models/User';\nimport { default as User } from \"../models/User\";\nimport { Request, Response, NextFunction } from \"express\";\n\nconst LocalStrategy = passportLocal.Strategy;\nconst FacebookStrategy = passportFacebook.Strategy;\n\npassport.serializeUser((user, done) => {\n done(undefined, user.id);\n});\n\npassport.deserializeUser((id, done) => {\n User.findById(id, (err, user) => {\n done(err, user);\n });\n});\n\n\n/**\n * Sign in using Email and Password.\n */\npassport.use(new LocalStrategy({ usernameField: \"email\" }, (email, password, done) => {\n User.findOne({ email: email.toLowerCase() }, (err, user: any) => {\n if (err) { return done(err); }\n if (!user) {\n return done(undefined, false, { message: `Email ${email} not found.` });\n }\n user.comparePassword(password, (err: Error, isMatch: boolean) => {\n if (err) { return done(err); }\n if (isMatch) {\n return done(undefined, user);\n }\n return done(undefined, false, { message: \"Invalid email or password.\" });\n });\n });\n}));\n\n\n/**\n * OAuth Strategy Overview\n *\n * - User is already logged in.\n * - Check if there is an existing account with a provider id.\n * - If there is, return an error message. (Account merging not supported)\n * - Else link new OAuth account with currently logged-in user.\n * - User is not logged in.\n * - Check if it's a returning user.\n * - If returning user, sign in and we are done.\n * - Else check if there is an existing account with user's email.\n * - If there is, return an error message.\n * - Else create a new account.\n */\n\n\n/**\n * Sign in with Facebook.\n */\npassport.use(new FacebookStrategy({\n clientID: process.env.FACEBOOK_ID,\n clientSecret: process.env.FACEBOOK_SECRET,\n callbackURL: \"/auth/facebook/callback\",\n profileFields: [\"name\", \"email\", \"link\", \"locale\", \"timezone\"],\n passReqToCallback: true\n}, (req: any, accessToken, refreshToken, profile, done) => {\n if (req.user) {\n User.findOne({ facebook: profile.id }, (err, existingUser) => {\n if (err) { return done(err); }\n if (existingUser) {\n req.flash(\"errors\", { msg: \"There is already a Facebook account that belongs to you. Sign in with that account or delete it, then link it with your current account.\" });\n done(err);\n } else {\n User.findById(req.user.id, (err, user: any) => {\n if (err) { return done(err); }\n user.facebook = profile.id;\n user.tokens.push({ kind: \"facebook\", accessToken });\n user.profile.name = user.profile.name || `${profile.name.givenName} ${profile.name.familyName}`;\n user.profile.gender = user.profile.gender || profile._json.gender;\n user.profile.picture = user.profile.picture || `https://graph.facebook.com/${profile.id}/picture?type=large`;\n user.save((err: Error) => {\n req.flash(\"info\", { msg: \"Facebook account has been linked.\" });\n done(err, user);\n });\n });\n }\n });\n } else {\n User.findOne({ facebook: profile.id }, (err, existingUser) => {\n if (err) { return done(err); }\n if (existingUser) {\n return done(undefined, existingUser);\n }\n User.findOne({ email: profile._json.email }, (err, existingEmailUser) => {\n if (err) { return done(err); }\n if (existingEmailUser) {\n req.flash(\"errors\", { msg: \"There is already an account using this email address. Sign in to that account and link it with Facebook manually from Account Settings.\" });\n done(err);\n } else {\n const user: any = new User();\n user.email = profile._json.email;\n user.facebook = profile.id;\n user.tokens.push({ kind: \"facebook\", accessToken });\n user.profile.name = `${profile.name.givenName} ${profile.name.familyName}`;\n user.profile.gender = profile._json.gender;\n user.profile.picture = `https://graph.facebook.com/${profile.id}/picture?type=large`;\n user.profile.location = (profile._json.location) ? profile._json.location.name : \"\";\n user.save((err: Error) => {\n done(err, user);\n });\n }\n });\n });\n }\n}));\n\n/**\n * Login Required middleware.\n */\nexport let isAuthenticated = (req: Request, res: Response, next: NextFunction) => {\n if (req.isAuthenticated()) {\n return next();\n }\n res.redirect(\"/login\");\n};\n\n/**\n * Authorization Required middleware.\n */\nexport let isAuthorized = (req: Request, res: Response, next: NextFunction) => {\n const provider = req.path.split(\"/\").slice(-1)[0];\n\n if (_.find(req.user.tokens, { kind: provider })) {\n next();\n } else {\n res.redirect(`/auth/${provider}`);\n }\n};\n", + "language": "javascript", + "qnames": [ + "\"passport\"", + "_", + "user", + "FacebookStrategy", + "isAuthenticated", + "isAuthorized", + "provider", + "LocalStrategy", + "NextFunction", + "passport", + "passportFacebook", + "passportLocal", + "request", + "Request", + "Response", + "User" + ] + } + } +} diff --git a/x-pack/test/functional/es_archives/code/mappings.json b/x-pack/test/functional/es_archives/code/mappings.json new file mode 100644 index 000000000000000..4cd04d4116b0ffc --- /dev/null +++ b/x-pack/test/functional/es_archives/code/mappings.json @@ -0,0 +1,243 @@ +{ + "type": "index", + "value": { + "index": ".code-document-github.com-microsoft-typescript-node-starter-1", + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "1", + "analysis": { + "analyzer": { + "lowercase_analyzer": { + "filter": [ + "lowercase" + ], + "type": "custom", + "tokenizer": "keyword" + }, + "path_hierarchy_analyzer": { + "filter": [ + "lowercase" + ], + "type": "custom", + "tokenizer": "path_hierarchy_tokenizer" + }, + "content_analyzer": { + "filter": [ + "lowercase" + ], + "char_filter": [ + "content_char_filter" + ], + "tokenizer": "standard" + }, + "qname_path_hierarchy_analyzer": { + "filter": [ + "lowercase" + ], + "type": "custom", + "tokenizer": "qname_path_hierarchy_tokenizer" + }, + "path_analyzer": { + "filter": [ + "lowercase" + ], + "type": "custom", + "tokenizer": "path_tokenizer" + } + }, + "char_filter": { + "content_char_filter": { + "pattern": "[.]", + "type": "pattern_replace", + "replacement": " " + } + }, + "tokenizer": { + "path_hierarchy_tokenizer": { + "reverse": "true", + "type": "path_hierarchy", + "delimiter": "/" + }, + "path_tokenizer": { + "pattern": "[\\\\./]", + "type": "pattern" + }, + "qname_path_hierarchy_tokenizer": { + "reverse": "true", + "type": "path_hierarchy", + "delimiter": "." + } + } + } + } + }, + "mappings": { + "document": { + "_meta": { + "version": 1 + }, + "dynamic_templates": [ + { + "fieldDefaultNotAnalyzed": { + "match": "*", + "mapping": { + "index": false, + "norms": false + } + } + } + ], + "properties": { + "content": { + "type": "text", + "analyzer": "content_analyzer" + }, + "language": { + "type": "keyword" + }, + "path": { + "type": "text", + "fields": { + "hierarchy": { + "type": "text", + "analyzer": "path_hierarchy_analyzer" + } + }, + "analyzer": "path_analyzer" + }, + "qnames": { + "type": "text", + "analyzer": "qname_path_hierarchy_analyzer" + }, + "repoUri": { + "type": "keyword" + }, + "repository": { + "properties": { + "defaultBranch": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "org": { + "type": "text" + }, + "revision": { + "type": "keyword" + }, + "uri": { + "type": "text" + }, + "url": { + "type": "text", + "index": false + } + } + }, + "repository_config": { + "properties": { + "disableGo": { + "type": "boolean" + }, + "disableJava": { + "type": "boolean" + }, + "disableTypescript": { + "type": "boolean" + }, + "uri": { + "type": "text" + } + } + }, + "repository_delete_status": { + "properties": { + "progress": { + "type": "integer" + }, + "revision": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "uri": { + "type": "text" + } + } + }, + "repository_git_status": { + "properties": { + "cloneProgress": { + "properties": { + "indexedDeltas": { + "type": "integer" + }, + "indexedObjects": { + "type": "integer" + }, + "isCloned": { + "type": "boolean" + }, + "localObjects": { + "type": "integer" + }, + "receivedBytes": { + "type": "integer" + }, + "receivedObjects": { + "type": "integer" + }, + "totalDeltas": { + "type": "integer" + }, + "totalObjects": { + "type": "integer" + } + } + }, + "progress": { + "type": "integer" + }, + "revision": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "uri": { + "type": "text" + } + } + }, + "repository_lsp_index_status": { + "properties": { + "progress": { + "type": "integer" + }, + "revision": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "uri": { + "type": "text" + } + } + }, + "sha1": { + "type": "text", + "index": false, + "norms": false + } + } + } + }, + "aliases": { + ".code-document-github.com-microsoft-typescript-node-starter": {} + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/page_objects/code_page.ts b/x-pack/test/functional/page_objects/code_page.ts new file mode 100644 index 000000000000000..203902dcc1b1381 --- /dev/null +++ b/x-pack/test/functional/page_objects/code_page.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 testSubjSelector from '@kbn/test-subj-selector'; +// import Keys from 'leadfoot/keys'; +// import moment from 'moment'; + +import { KibanaFunctionalTestDefaultProviders } from '../../types/providers'; + +export function CodeHomePageProvider({ getService }: KibanaFunctionalTestDefaultProviders) { + const testSubjects = getService('testSubjects'); + const log = getService('log'); + const queryBar = getService('queryBar'); + + return { + async fillImportRepositoryUrlInputBox(repoUrl: string) { + return await testSubjects.setValue('importRepositoryUrlInputBox', repoUrl); + }, + + async fillSearchQuery(query: string) { + // return await testSubjects.setValue('queryInput', query); + await queryBar.setQuery(query); + }, + + async submitSearchQuery() { + await queryBar.submitQuery(); + }, + + async clickImportRepositoryButton() { + log.info('Click import repository button.'); + return await testSubjects.click('importRepositoryButton'); + }, + + async clickDeleteRepositoryButton() { + log.info('Click delete repository button.'); + return await testSubjects.click('deleteRepositoryButton'); + }, + }; +} diff --git a/x-pack/test/functional/page_objects/index.js b/x-pack/test/functional/page_objects/index.js index 571d9c4aef5ebc9..6a141cb22352c68 100644 --- a/x-pack/test/functional/page_objects/index.js +++ b/x-pack/test/functional/page_objects/index.js @@ -19,5 +19,6 @@ export { InfraLogsPageProvider } from './infra_logs_page'; export { GisPageProvider } from './gis_page'; export { StatusPagePageProvider } from './status_page'; export { UpgradeAssistantProvider } from './upgrade_assistant'; +export { CodeHomePageProvider } from './code_page'; export { RollupPageProvider } from './rollup_page'; export { UptimePageProvider } from './uptime_page'; diff --git a/yarn.lock b/yarn.lock index 37060df2075d1f0..2b86f395a1e79ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1393,6 +1393,24 @@ oppsy "2.x.x" pumpify "1.3.x" +"@elastic/javascript-typescript-langserver@^0.1.23": + version "0.1.23" + resolved "https://registry.yarnpkg.com/@elastic/javascript-typescript-langserver/-/javascript-typescript-langserver-0.1.23.tgz#84401643e33d54570fd44a0541d6c176805fb722" + integrity sha512-L9P9LsKWt67hdgHJh+oTxQn6/F4fn7hpHu+jXuRaaysmddZ9IrSeg3y/1q1TPIsftgWgjQL31agD0Sm6/F/kOg== + dependencies: + "@elastic/lsp-extension" "^0.1.1" + javascript-typescript-langserver "^2.11.3" + rxjs "^5.5.0" + typescript "~3.3.3333" + +"@elastic/lsp-extension@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@elastic/lsp-extension/-/lsp-extension-0.1.1.tgz#b1f252e266986a181b8f1669ddde393a2a004624" + integrity sha512-4HsihoA0lGKH1euMueelnzkFVo3WHN01aBTFBAI8r9D2st3T3nveRjMkWrznAhZ6vfby8U61ssQf4+NjBB019g== + dependencies: + vscode-languageserver "^4.2.1" + vscode-languageserver-types "^3.10.0" + "@elastic/makelogs@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@elastic/makelogs/-/makelogs-4.4.0.tgz#bfe9d774afdaa923583e436e3c8459ded5545247" @@ -1414,6 +1432,22 @@ resolved "https://registry.yarnpkg.com/@elastic/node-crypto/-/node-crypto-0.1.2.tgz#c18ac282f635e88f041cc1555d806e492ca8f3b1" integrity sha1-wYrCgvY16I8EHMFVXYBuSSyo87E= +"@elastic/nodegit@0.25.0-alpha.12": + version "0.25.0-alpha.12" + resolved "https://registry.yarnpkg.com/@elastic/nodegit/-/nodegit-0.25.0-alpha.12.tgz#6dffdbea640f8b297af75e96f84c802427dff7f7" + integrity sha512-wKTji45igEw3VP2DmgLXpDX3n6WwOy0y4g/Xs385pymn9HWPVyg/UdWLJyXLrl0V//5EDSeqehMqOwTqAQ+qyA== + dependencies: + fs-extra "^7.0.0" + json5 "^2.1.0" + lodash "^4.17.11" + nan "^2.11.1" + node-gyp "^3.8.0" + node-pre-gyp "^0.11.0" + promisify-node "~0.3.0" + ramda "^0.25.0" + request-promise-native "^1.0.5" + tar-fs "^1.16.3" + "@elastic/numeral@2.3.3": version "2.3.3" resolved "https://registry.yarnpkg.com/@elastic/numeral/-/numeral-2.3.3.tgz#94d38a35bd315efa7a6918b22695128fc40a885e" @@ -2111,12 +2145,28 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== -"@sinonjs/formatio@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2" - integrity sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg== +"@sinonjs/commons@^1.0.2", "@sinonjs/commons@^1.2.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.3.0.tgz#50a2754016b6f30a994ceda6d9a0a8c36adda849" + integrity sha512-j4ZwhaHmwsCb4DlDOIWnI5YyKDNMoNThsmwEpfHx6a1EpsGZ9qYLxP++LMlmBRjtGptGHFsGItJ768snllFWpA== dependencies: - samsam "1.3.0" + type-detect "4.0.8" + +"@sinonjs/formatio@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.1.0.tgz#6ac9d1eb1821984d84c4996726e45d1646d8cce5" + integrity sha512-ZAR2bPHOl4Xg6eklUGpsdiIJ4+J1SNag1DHHrG/73Uz/nVwXqjgUtRPLoS+aVyieN9cSbc0E4LsU984tWcDyNg== + dependencies: + "@sinonjs/samsam" "^2 || ^3" + +"@sinonjs/samsam@^2 || ^3", "@sinonjs/samsam@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.0.2.tgz#304fb33bd5585a0b2df8a4c801fcb47fa84d8e43" + integrity sha512-m08g4CS3J6lwRQk1pj1EO+KEVWbrbXsmi9Pw0ySmrIbcVxVaedoFgLvFsV8wHLwh01EpROVz3KvVcD1Jmks9FQ== + dependencies: + "@sinonjs/commons" "^1.0.2" + array-from "^2.1.1" + lodash.get "^4.4.2" "@slack/client@^4.8.0": version "4.8.0" @@ -2972,6 +3022,11 @@ resolved "https://registry.yarnpkg.com/@types/getopts/-/getopts-2.0.1.tgz#b7e5478fe7571838b45aff736a59ab69b8bcda18" integrity sha512-JsQJHtzLYKunMz7acYOX6x5IJ/42CsjjHFfLCmis1Hn/qFoD/y0kJFUIAg8HoDigQpKUcWj55d8rxZQBnvz1Pw== +"@types/git-url-parse@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" + integrity sha512-kA2RxBT/r/ZuDDKwMl+vFWn1Z0lfm1/Ik6Qb91wnSzyzCDa/fkM8gIOq6ruB7xfr37n6Mj5dyivileUVKsidlg== + "@types/glob@*", "@types/glob@^5.0.35": version "5.0.35" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-5.0.35.tgz#1ae151c802cece940443b5ac246925c85189f32a" @@ -2981,6 +3036,15 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/glob@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" + integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w== + dependencies: + "@types/events" "*" + "@types/minimatch" "*" + "@types/node" "*" + "@types/globby@^6.1.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/globby/-/globby-6.1.0.tgz#7c25b975512a89effea2a656ca8cf6db7fb29d11" @@ -3254,6 +3318,20 @@ dependencies: "@types/node" "*" +"@types/nock@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@types/nock/-/nock-9.3.0.tgz#9d34358fdcc08afd07144e0784ac9e951d412dd4" + integrity sha512-ZHf/X8rTQ5Tb1rHjxIJYqm55uO265agE3G7NoSXVa2ep+EcJXgB2fsme+zBvK7MhrxTwkC/xkB6THyv50u0MGw== + dependencies: + "@types/node" "*" + +"@types/node-fetch@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.1.4.tgz#093d1beae11541aef25999d70aa09286fd025b1a" + integrity sha512-tR1ekaXUGpmzOcDXWU9BW73YfA2/VW1DF1FH+wlJ82BbCSnWTbdX+JkqWQXWKIGsFPnPsYadbXfNgz28g+ccWg== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@10.12.27", "@types/node@8.5.8", "@types/node@>=6.0.0", "@types/node@^10.12.27": version "10.12.27" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.27.tgz#eb3843f15d0ba0986cc7e4d734d2ee8b50709ef8" @@ -3305,6 +3383,13 @@ dependencies: "@types/retry" "*" +"@types/papaparse@^4.5.5": + version "4.5.6" + resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-4.5.6.tgz#05e4d1b97dd25065235777c49bd43685f066bf5f" + integrity sha512-k+n8uR0yJtexBSzwNmxrOkgTlPImFWU3J1Ye7DjkqZIOXyypUxuWvo6xlOfTfYbgPDebrdYmysiuSb6UMvP6FQ== + dependencies: + "@types/node" "*" + "@types/pngjs@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.1.tgz#47d97bd29dd6372856050e9e5e366517dd1ba2d8" @@ -3334,6 +3419,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.9.tgz#f2d14df87b0739041bc53a7d75e3d77d726a3ec0" integrity sha512-Nha5b+jmBI271jdTMwrHiNXM+DvThjHOfyZtMX9kj/c/LUj2xiLHsG/1L3tJ8DjAoQN48cHwUwtqBotjyXaSdQ== +"@types/proper-lockfile@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-3.0.0.tgz#dcc7cc3714857a4ae6583331d2687e89dc5c94d2" + integrity sha512-+tfnsA3KNPDm7Sj9x5omRgvS6ALc+edjTZXYeR3kVEm+qmsrF+59yJUWZDreV/O0+EQ6t0YSWlzxfdV58UOEVg== + "@types/puppeteer-core@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@types/puppeteer-core/-/puppeteer-core-1.9.0.tgz#5ceb397e3ff769081fb07d71289b5009392d24d3" @@ -3404,6 +3494,13 @@ "@types/history" "*" "@types/react" "*" +"@types/react-test-renderer@^16.8.0": + version "16.8.1" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.8.1.tgz#96f3ce45a3a41c94eca532a99103dd3042c9d055" + integrity sha512-8gU69ELfJGxzVWVYj4MTtuHxz9nO+d175XeQ1XrXXxesUBsB4KK6OCfzVhEX6leZWWBDVtMJXp/rUjhClzL7gw== + dependencies: + "@types/react" "*" + "@types/react-virtualized@^9.18.7": version "9.18.7" resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.18.7.tgz#8703d8904236819facff90b8b320f29233160c90" @@ -3498,10 +3595,10 @@ dependencies: "@types/node" "*" -"@types/sinon@^5.0.1": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-5.0.3.tgz#2b1840122f372350c563e3ceda2f447b55f3a927" - integrity sha512-JvnfqYfBapg1Ktjjvb79myQ2A848yCdB+1g/okS9OyNqPrT/qWUIt71G0eyi7msZf0gC4fBd40Yu15Btw6BMfQ== +"@types/sinon@^7.0.0": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.3.tgz#f8647e883d873962130f906a6114a4e187755696" + integrity sha512-cjmJQLx2B5Hp9SzO7rdSivipo3kBqRqeYkTW17nLST1tn5YLWBjTdnzdmeTJXA1+KrrBLsEuvKQ0fUPGrfazQg== "@types/storybook__addon-actions@^3.4.2": version "3.4.2" @@ -3567,6 +3664,13 @@ dependencies: "@types/superagent" "*" +"@types/tar-fs@^1.16.1": + version "1.16.1" + resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.1.tgz#6e3fba276c173e365ae91e55f7b797a0e64298e5" + integrity sha512-uQQIaa8ukcKf/1yy2kzfP1PF+7jEZghFDKpDvgtsYo/mbqM1g4Qza1Y5oAw6kJMa7eLA/HkmxUsDqb2sWKVF9g== + dependencies: + "@types/node" "*" + "@types/tempy@^0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@types/tempy/-/tempy-0.1.0.tgz#8ba0339dcd5abb554f301683dc3396d153ec5bfd" @@ -4503,6 +4607,11 @@ ansi-align@^3.0.0: dependencies: string-width "^3.0.0" +ansi-color@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/ansi-color/-/ansi-color-0.2.1.tgz#3e75c037475217544ed763a8db5709fa9ae5bf9a" + integrity sha1-PnXAN0dSF1RO12Oo21cJ+prlv5o= + ansi-colors@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9" @@ -4647,6 +4756,11 @@ any-observable@^0.3.0: resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= + anymatch@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" @@ -4989,6 +5103,11 @@ array-flatten@^2.1.0: resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.1.tgz#426bb9da84090c1838d812c8150af20a8331e296" integrity sha1-Qmu52oQJDBg42BLIFQryCoMx4pY= +array-from@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/array-from/-/array-from-2.1.1.tgz#cfe9d8c26628b9dc5aecc62a9f5d8f1f352c1195" + integrity sha1-z+nYwmYoudxa7MYqn12PHzUsEZU= + array-includes@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d" @@ -5115,7 +5234,7 @@ assert@^1.1.1: dependencies: util "0.10.3" -assertion-error@^1.0.1: +assertion-error@^1.0.1, assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== @@ -5296,6 +5415,11 @@ attr-accept@^1.1.3: dependencies: core-js "^2.5.0" +autobind-decorator@^1.3.4: + version "1.4.3" + resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-1.4.3.tgz#4c96ffa77b10622ede24f110f5dbbf56691417d1" + integrity sha1-TJb/p3sQYi7eJPEQ9du/VmkUF9E= + autolinker@~0.15.0: version "0.15.3" resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-0.15.3.tgz#342417d8f2f3461b14cf09088d5edf8791dc9832" @@ -6525,6 +6649,15 @@ buffered-spawn@~1.1.1: err-code "^0.1.0" q "^1.0.1" +bufrw@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/bufrw/-/bufrw-1.2.1.tgz#93f222229b4f5f5e2cd559236891407f9853663b" + integrity sha1-k/IiIptPX14s1VkjaJFAf5hTZjs= + dependencies: + ansi-color "^0.2.1" + error "^7.0.0" + xtend "^4.0.0" + builtin-modules@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" @@ -6855,7 +6988,14 @@ center-align@^0.1.1: align-text "^0.1.3" lazy-cache "^1.0.3" -chai@3.5.0, "chai@>=1.9.2 <4.0.0": +chai-as-promised@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-7.1.1.tgz#08645d825deb8696ee61725dbf590c012eb00ca0" + integrity sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA== + dependencies: + check-error "^1.0.2" + +chai@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247" integrity sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc= @@ -6864,6 +7004,18 @@ chai@3.5.0, "chai@>=1.9.2 <4.0.0": deep-eql "^0.1.3" type-detect "^1.0.0" +chai@^4.0.1, chai@^4.1.2: + version "4.2.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5" + integrity sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^3.0.1" + get-func-name "^2.0.0" + pathval "^1.1.0" + type-detect "^4.0.5" + chalk@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e" @@ -6900,7 +7052,7 @@ chalk@2.4.1, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.1, chalk@^2.4. escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@2.4.2, chalk@^2.3.2, chalk@^2.4.2, chalk@~2.4.1: +chalk@2.4.2, chalk@^2.2.0, chalk@^2.3.2, chalk@^2.4.2, chalk@~2.4.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -7016,6 +7168,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +check-error@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" + integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= + checksum@0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/checksum/-/checksum-0.1.1.tgz#dc6527d4c90be8560dbd1ed4cecf3297d528e9e9" @@ -9071,6 +9228,13 @@ deep-eql@^0.1.3: dependencies: type-detect "0.1.1" +deep-eql@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" + integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw== + dependencies: + type-detect "^4.0.0" + deep-equal@^1.0.0, deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -9359,16 +9523,16 @@ diff@^2.1.2: resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99" integrity sha1-YOr9DSjukG5Oj/ClLBIpUhAzv5k= -diff@^3.1.0, diff@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" - integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== - diff@^3.2.0: version "3.4.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.4.0.tgz#b1d85507daf3964828de54b37d0d73ba67dda56c" integrity sha512-QpVuMTEoJMF7cKzi6bvWhRulU1fZqZnvyVQgNhPaxxuTYwyjn/j1v9falseQ/uXWwPnO56RBfwtg4h/EQXmucA== +diff@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + diffie-hellman@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" @@ -10036,7 +10200,7 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -error@^7.0.0, error@^7.0.2: +error@7.0.2, error@^7.0.0, error@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02" integrity sha1-pfdf/02ZJhJt2sDqXcOOaJFTywI= @@ -11119,6 +11283,13 @@ fast-glob@^2.0.2: merge2 "1.2.1" micromatch "3.1.5" +fast-json-patch@^2.0.2: + version "2.0.7" + resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-2.0.7.tgz#55864b08b1e50381d2f37fd472bb2e18fe54a733" + integrity sha512-DQeoEyPYxdTtfmB3yDlxkLyKTdbJ6ABfFGcMynDqjvGhPYLto/pZyb/dG2Nyd/n9CArjEWN9ZST++AFmgzgbGw== + dependencies: + deep-equal "^1.0.1" + fast-json-stable-stringify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" @@ -11293,6 +11464,11 @@ file-system-cache@^1.0.5: fs-extra "^0.30.0" ramda "^0.21.0" +file-type@^10.9.0: + version "10.9.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-10.9.0.tgz#f6c12c7cb9e6b8aeefd6917555fd4f9eadf31891" + integrity sha512-9C5qtGR/fNibHC5gzuMmmgnjH3QDDLKMa8lYe9CiZVmAnI4aUaoMh40QyUPzzs0RYo837SOBKh7TYwle4G8E4w== + file-type@^3.8.0: version "3.9.0" resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" @@ -11780,7 +11956,7 @@ fs-extra@^4.0.1: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^7.0.1, fs-extra@~7.0.1: +fs-extra@^7.0.0, fs-extra@^7.0.1, fs-extra@~7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== @@ -11980,17 +12156,20 @@ get-caller-file@^1.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== +get-func-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" + integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= + get-own-enumerable-property-symbols@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-2.0.1.tgz#5c4ad87f2834c4b9b4e84549dc1e0650fb38c24b" integrity sha512-TtY/sbOemiMKPRUDDanGCSgBYe7Mf0vbRsWnBZ+9yghpZ1MvcpSpuZFjHdEeY/LZjZy0vdLjS77L6HosisFiug== -get-port@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/get-port/-/get-port-2.1.0.tgz#8783f9dcebd1eea495a334e1a6a251e78887ab1a" - integrity sha1-h4P53OvR7qSVozThpqJR54iHqxo= - dependencies: - pinkie-promise "^2.0.0" +get-port@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119" + integrity sha512-/b3jarXkH8KJoOMQc3uVGHASwGLPq3gSFJ7tgJm2diza+bydJPTGOibin2steecKeOylE8oY2JERlVWkAJO6yw== get-proxy@^2.0.0: version "2.1.0" @@ -12080,6 +12259,26 @@ git-config-path@^1.0.1: fs-exists-sync "^0.1.0" homedir-polyfill "^1.0.0" +git-up@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/git-up/-/git-up-4.0.1.tgz#cb2ef086653640e721d2042fe3104857d89007c0" + integrity sha512-LFTZZrBlrCrGCG07/dm1aCjjpL1z9L3+5aEeI9SBhAqSc+kiA9Or1bgZhQFNppJX6h/f5McrvJt1mQXTFm6Qrw== + dependencies: + is-ssh "^1.3.0" + parse-url "^5.0.0" + +git-url-parse@11.1.2: + version "11.1.2" + resolved "https://registry.yarnpkg.com/git-url-parse/-/git-url-parse-11.1.2.tgz#aff1a897c36cc93699270587bea3dbcbbb95de67" + integrity sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ== + dependencies: + git-up "^4.0.0" + +github-markdown-css@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/github-markdown-css/-/github-markdown-css-2.10.0.tgz#0612fed22816b33b282f37ef8def7a4ecabfe993" + integrity sha512-RX5VUC54uX6Lvrm226M9kMzsNeOa81MnKyxb3J0G5KLjyoOySOZgwyKFkUpv6iUhooiUZdogk+OTwQPJ4WttYg== + github-username@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/github-username/-/github-username-3.0.0.tgz#0a772219b3130743429f2456d0bdd3db55dce7b1" @@ -12183,17 +12382,6 @@ glob@3.2.11: inherits "2" minimatch "0.3" -glob@6.0.4, glob@^6.0.1, glob@^6.0.4: - version "6.0.4" - resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" - integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI= - dependencies: - inflight "^1.0.4" - inherits "2" - minimatch "2 || 3" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" @@ -12239,6 +12427,17 @@ glob@^5.0.14, glob@^5.0.15, glob@~5.0.0, glob@~5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^6.0.1, glob@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI= + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@~3.1.21: version "3.1.21" resolved "https://registry.yarnpkg.com/glob/-/glob-3.1.21.tgz#d29e0a055dea5138f4d07ed40e8982e83c2066cd" @@ -13727,6 +13926,11 @@ icss-utils@^4.1.0: dependencies: postcss "^7.0.14" +idx@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/idx/-/idx-2.5.2.tgz#4b405c2e6d68d04136e0a368a7ab35b9caa0595f" + integrity sha512-MLoGF4lQU5q/RqJJjRsuid52emu7tPVtSSZaYXsqRvSjvXdBEmIwk2urvbNvPBRU9Ox9I4WYnxiz2GjhU34Lrw== + ieee754@^1.1.4: version "1.1.8" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" @@ -13786,7 +13990,7 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== -immer@^1.12.0: +immer@^1.12.0, immer@^1.5.0: version "1.12.1" resolved "https://registry.yarnpkg.com/immer/-/immer-1.12.1.tgz#40c6e5b292c00560836c2993bda3a24379d466f5" integrity sha512-3fmKM6ovaqDt0CdC9daXpNi5x/YCYS3i4cwLdTVkhJdk5jrDXoPs7lCm3IqM3yhfSnz4tjjxbRG2CziQ7m8ztg== @@ -14731,6 +14935,13 @@ is-scoped@^1.0.0: dependencies: scoped-regex "^1.0.0" +is-ssh@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.3.1.tgz#f349a8cadd24e65298037a522cf7520f2e81a0f3" + integrity sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg== + dependencies: + protocols "^1.1.0" + is-stream@1.1.0, is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -14865,6 +15076,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +isnumber@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isnumber/-/isnumber-1.0.0.tgz#0e3f9759b581d99dd85086f0ec2a74909cfadd01" + integrity sha1-Dj+XWbWB2Z3YUIbw7Cp0kJz63QE= + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -15036,6 +15252,11 @@ iterall@^1.1.3, iterall@^1.2.1: resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7" integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA== +iterare@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/iterare/-/iterare-1.1.2.tgz#32e65fe03c72f727b1ae5fd002ed6a215f523ae8" + integrity sha512-25rVYmj/dDvTR6zOa9jY1Ihd6USLa0J508Ub2iy7Aga+xu9JMbjDds2Uh03ReDGbva/YN3s3Ybi+Do0nOX6wAg== + jade@0.26.3: version "0.26.3" resolved "https://registry.yarnpkg.com/jade/-/jade-0.26.3.tgz#8f10d7977d8d79f2f6ff862a81b0513ccb25686c" @@ -15044,6 +15265,42 @@ jade@0.26.3: commander "0.6.1" mkdirp "0.3.0" +jaeger-client@^3.5.3: + version "3.13.0" + resolved "https://registry.yarnpkg.com/jaeger-client/-/jaeger-client-3.13.0.tgz#c5b228242d65389a13eb24eeb56a55409d72c94e" + integrity sha512-ykrXLxcmSHSdDXqK6/DY+IObekfj4kbONC3QPu/ln7sbY5bsA+Yu4LYVlW9/vLm0lxLlsz52mSyC+sjiqM8xCw== + dependencies: + node-int64 "^0.4.0" + opentracing "^0.13.0" + thriftrw "^3.5.0" + uuid "^3.2.1" + xorshift "^0.2.0" + +javascript-typescript-langserver@^2.11.3: + version "2.11.3" + resolved "https://registry.yarnpkg.com/javascript-typescript-langserver/-/javascript-typescript-langserver-2.11.3.tgz#71bd0b08e43588f1d49a85eb774acc02a3d9a26d" + integrity sha512-j2dKPq5tgSUyM2AOXWh2O7pNWzXzKI/3W02X1OrEZnV3B9yt9IM+snuGt/mk1Nryxyy7OZnhdL0XqHe4xx7Qzw== + dependencies: + chai "^4.0.1" + chai-as-promised "^7.0.0" + chalk "^2.2.0" + commander "^2.9.0" + fast-json-patch "^2.0.2" + glob "^7.1.1" + iterare "^1.1.2" + jaeger-client "^3.5.3" + lodash "^4.17.4" + mz "^2.6.0" + object-hash "^1.1.8" + opentracing "^0.14.0" + rxjs "^5.5.0" + semaphore-async-await "^1.5.1" + string-similarity "^2.0.0" + typescript "~3.0.3" + vscode-jsonrpc "^4.0.0" + vscode-languageserver "^5.0.0" + vscode-languageserver-types "^3.0.3" + jest-changed-files@^24.0.0: version "24.0.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.0.0.tgz#c02c09a8cc9ca93f513166bc773741bd39898ff7" @@ -15888,10 +16145,10 @@ jszip@^3.1.3: readable-stream "~2.3.6" set-immediate-shim "~1.0.1" -just-extend@^1.1.27: - version "1.1.27" - resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" - integrity sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g== +just-extend@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc" + integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw== just-reduce-object@^1.0.3: version "1.1.0" @@ -16874,7 +17131,7 @@ lodash.uniqby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" integrity sha1-2ZwHpmnp5tJOE2Lf4mbGdhavEwI= -lodash@>4.17.4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.11.1, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.5.0, lodash@^4.6.1, lodash@^4.8.2, lodash@~4.17.10, lodash@~4.17.5: +lodash@>4.17.4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.11.1, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.5.0, lodash@^4.6.1, lodash@~4.17.10, lodash@~4.17.5: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== @@ -16964,11 +17221,21 @@ loglevelnext@^1.0.1: es6-symbol "^3.1.1" object.assign "^4.1.0" -lolex@^2.2.0, lolex@^2.3.2: +lolex@^2.3.2: version "2.6.0" resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.6.0.tgz#cf9166f3c9dece3cdeb5d6b01fce50f14a1203e3" integrity sha512-e1UtIo1pbrIqEXib/yMjHciyqkng5lc0rrIbytgjmRgDR9+2ceNIAcwOWSgylRjoEP9VdVguCSRwnNmlbnOUwA== +lolex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-3.0.0.tgz#f04ee1a8aa13f60f1abd7b0e8f4213ec72ec193e" + integrity sha512-hcnW80h3j2lbUfFdMArd5UPA/vxZJ+G8vobd+wg3nVEQA0EigStbYcrG030FJxL6xiDDPEkoMatV9xIh5OecQQ== + +long@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/long/-/long-2.4.0.tgz#9fa180bb1d9500cdc29c4156766a1995e1f4524f" + integrity sha1-n6GAux2VAM3CnEFWdmoZleH0Uk8= + longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" @@ -17246,6 +17513,13 @@ md5.js@^1.3.4: hash-base "^3.0.0" inherits "^2.0.1" +mdast-add-list-metadata@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdast-add-list-metadata/-/mdast-add-list-metadata-1.0.1.tgz#95e73640ce2fc1fa2dcb7ec443d09e2bfe7db4cf" + integrity sha512-fB/VP4MJ0LaRsog7hGPxgOrSL3gE/2uEdZyDuSEnKCv/8IkYHiDkIQSbChiJoHyxZZXZ9bzckyRk+vNxFzh8rA== + dependencies: + unist-util-visit-parents "1.1.2" + mdn-data@~1.1.0: version "1.1.4" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-1.1.4.tgz#50b5d4ffc4575276573c4eedb8780812a8419f01" @@ -17824,6 +18098,11 @@ moment@^2.10.6: resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a" integrity sha512-TCZ36BjURTeFTM/CwRcViQlfkMvL1/vFISuNLO5GkcVm1+QHfbSiNqZuWeMFjj1/3+uAjXswgRk30j1kkLYJBQ== +monaco-editor@^0.14.3: + version "0.14.3" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.14.3.tgz#7cc4a4096a3821f52fea9b10489b527ef3034e22" + integrity sha512-RhaO4xXmWn/p0WrkEOXe4PoZj6xOcvDYjoAh0e1kGUrQnP1IOpc0m86Ceuaa2CLEMDINqKijBSmqhvBQnsPLHQ== + monotone-convex-hull-2d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/monotone-convex-hull-2d/-/monotone-convex-hull-2d-1.0.1.tgz#47f5daeadf3c4afd37764baa1aa8787a40eee08c" @@ -17946,11 +18225,25 @@ mute-stream@0.0.7: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= +mz@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + nan@^2.10.0, nan@^2.9.2: version "2.10.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA== +nan@^2.11.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552" + integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw== + nanomatch@^1.2.5: version "1.2.7" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.7.tgz#53cd4aa109ff68b7f869591fdc9d10daeeea3e79" @@ -18078,13 +18371,13 @@ nigel@3.x.x: hoek "5.x.x" vise "3.x.x" -nise@^1.2.0: - version "1.3.3" - resolved "https://registry.yarnpkg.com/nise/-/nise-1.3.3.tgz#c17a850066a8a1dfeb37f921da02441afc4a82ba" - integrity sha512-v1J/FLUB9PfGqZLGDBhQqODkbLotP0WtLo9R4EJY2PPu5f5Xg4o0rA8FDlmrjFSv9vBBKcfnOSpfYYuu5RTHqg== +nise@^1.4.7: + version "1.4.8" + resolved "https://registry.yarnpkg.com/nise/-/nise-1.4.8.tgz#ce91c31e86cf9b2c4cac49d7fcd7f56779bfd6b0" + integrity sha512-kGASVhuL4tlAV0tvA34yJYZIVihrUt/5bDwpp4tTluigxUr2bBlJeDXmivb6NuEdFkqvdv/Ybb9dm16PSKUhtw== dependencies: - "@sinonjs/formatio" "^2.0.0" - just-extend "^1.1.27" + "@sinonjs/formatio" "^3.1.0" + just-extend "^4.0.2" lolex "^2.3.2" path-to-regexp "^1.7.0" text-encoding "^0.6.4" @@ -18101,19 +18394,20 @@ no-ui-slider@1.2.0: resolved "https://registry.yarnpkg.com/no-ui-slider/-/no-ui-slider-1.2.0.tgz#1f64f5a8b82e6786f3261d82b0cc99b598817e69" integrity sha1-H2T1qLguZ4bzJh2CsMyZtZiBfmk= -nock@8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/nock/-/nock-8.0.0.tgz#f86d676568c73a3bb2144ebc80791d447bb334d2" - integrity sha1-+G1nZWjHOjuyFE68gHkdRHuzNNI= +nock@10.0.4: + version "10.0.4" + resolved "https://registry.yarnpkg.com/nock/-/nock-10.0.4.tgz#44f5dcfe0a6b09f95d541f6b3f057cfabbbd2a3a" + integrity sha512-+kzpiUmJHl2j/ZdJG4Mc3oHJc4F1Tm9j0KV/SLhLKZQGTQkeK2z1XxhVIbM2evP3yn0RVlp7L1xZNIy84J8/1A== dependencies: - chai ">=1.9.2 <4.0.0" - debug "^2.2.0" + chai "^4.1.2" + debug "^4.1.0" deep-equal "^1.0.0" json-stringify-safe "^5.0.1" - lodash "^4.8.2" + lodash "^4.17.5" mkdirp "^0.5.0" - propagate "0.4.0" - qs "^6.0.2" + propagate "^1.0.0" + qs "^6.5.1" + semver "^5.5.0" node-dir@^0.1.10: version "0.1.17" @@ -18245,6 +18539,22 @@ node-pre-gyp@^0.10.0: semver "^5.3.0" tar "^4" +node-pre-gyp@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" + integrity sha512-TwWAOZb0j7e9eGaf9esRx3ZcLaE5tQ2lvYy1pb5IAaG1a2e2Kv5Lms1Y4hpj+ciXJRofIxxlt5haeQ/2ANeE0Q== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.1" + needle "^2.2.1" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4" + node-releases@^1.1.11: version "1.1.11" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.11.tgz#9a0841a4b0d92b7d5141ed179e764f42ad22724a" @@ -18299,6 +18609,13 @@ node-version@^1.0.0: resolved "https://registry.yarnpkg.com/node-version/-/node-version-1.2.0.tgz#34fde3ffa8e1149bd323983479dda620e1b5060d" integrity sha512-ma6oU4Sk0qOoKEAymVoTvk8EdXEobdS7m/mAGhDJ8Rouugho48crHBORAmy5BoOcv8wraPM6xumapQp5hl4iIQ== +nodegit-promise@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/nodegit-promise/-/nodegit-promise-4.0.0.tgz#5722b184f2df7327161064a791d2e842c9167b34" + integrity sha1-VyKxhPLfcycWEGSnkdLoQskWezQ= + dependencies: + asap "~2.0.3" + nodemailer@^4.6.4: version "4.6.4" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.6.4.tgz#f0d72d0c6a6ec5f4369fa8f4bf5127a31baa2014" @@ -18369,6 +18686,11 @@ normalize-url@2.0.1: query-string "^5.0.1" sort-keys "^2.0.0" +normalize-url@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" + integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== + now-and-later@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.0.tgz#bc61cbb456d79cb32207ce47ca05136ff2e7d6ee" @@ -18514,7 +18836,7 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-hash@^1.3.1: +object-hash@^1.1.8, object-hash@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-1.3.1.tgz#fde452098a951cb145f039bb7d455449ddc126df" integrity sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA== @@ -18727,6 +19049,16 @@ openssl-self-signed-certificate@1.1.6: resolved "https://registry.yarnpkg.com/openssl-self-signed-certificate/-/openssl-self-signed-certificate-1.1.6.tgz#9d3a4776b1a57e9847350392114ad2f915a83dd4" integrity sha1-nTpHdrGlfphHNQOSEUrS+RWoPdQ= +opentracing@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/opentracing/-/opentracing-0.13.0.tgz#6a341442f09d7d866bc11ed03de1e3828e3d6aab" + integrity sha1-ajQUQvCdfYZrwR7QPeHjgo49aqs= + +opentracing@^0.14.0: + version "0.14.3" + resolved "https://registry.yarnpkg.com/opentracing/-/opentracing-0.14.3.tgz#23e3ad029fa66a653926adbe57e834469f8550aa" + integrity sha1-I+OtAp+mamU5Jq2+V+g0Rp+FUKo= + opn@5.2.0, opn@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.2.0.tgz#71fdf934d6827d676cecbea1531f95d354641225" @@ -19234,6 +19566,24 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= +parse-path@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-4.0.1.tgz#0ec769704949778cb3b8eda5e994c32073a1adff" + integrity sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA== + dependencies: + is-ssh "^1.3.0" + protocols "^1.4.0" + +parse-url@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-5.0.1.tgz#99c4084fc11be14141efa41b3d117a96fcb9527f" + integrity sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg== + dependencies: + is-ssh "^1.3.0" + normalize-url "^3.3.0" + parse-path "^4.0.0" + protocols "^1.4.0" + parse5@5.1.0, parse5@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" @@ -19397,6 +19747,11 @@ path-type@^2.0.0: dependencies: pify "^2.0.0" +pathval@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" + integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= + pbf@^3.0.5: version "3.1.0" resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.1.0.tgz#f70004badcb281761eabb1e76c92f179f08189e9" @@ -19643,6 +19998,11 @@ polished@^2.3.3: dependencies: "@babel/runtime" "^7.2.0" +popper.js@^1.14.3: + version "1.15.0" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" + integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== + popper.js@^1.14.4: version "1.14.7" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.7.tgz#e31ec06cfac6a97a53280c3e55e4e0c860e7738e" @@ -19994,6 +20354,13 @@ promise@^7.0.1, promise@^7.1.1: dependencies: asap "~2.0.3" +promisify-node@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/promisify-node/-/promisify-node-0.3.0.tgz#b4b55acf90faa7d2b8b90ca396899086c03060cf" + integrity sha1-tLVaz5D6p9K4uQyjlomQhsAwYM8= + dependencies: + nodegit-promise "~4.0.0" + prompts@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.0.3.tgz#c5ccb324010b2e8f74752aadceeb57134c1d2522" @@ -20044,10 +20411,19 @@ prop-types@^15.6.0, prop-types@^15.6.2: object-assign "^4.1.1" react-is "^16.8.1" -propagate@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/propagate/-/propagate-0.4.0.tgz#f3fcca0a6fe06736a7ba572966069617c130b481" - integrity sha1-8/zKCm/gZzanulcpZgaWF8EwtIE= +propagate@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-1.0.0.tgz#00c2daeedda20e87e3782b344adba1cddd6ad709" + integrity sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk= + +proper-lockfile@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-3.2.0.tgz#89ca420eea1d55d38ca552578851460067bcda66" + integrity sha512-iMghHHXv2bsxl6NchhEaFck8tvX3F9cknEEh1SUpguUOBjN7PAAW9BLzmbc1g/mCD1gY3EE2EABBHPJfFdHFmA== + dependencies: + graceful-fs "^4.1.11" + retry "^0.12.0" + signal-exit "^3.0.2" property-information@^5.0.0, property-information@^5.0.1: version "5.0.1" @@ -20066,6 +20442,11 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.3.2.tgz#00434f608b4e8df54c59e070efeefc37fb4bb859" integrity sha512-Xdayp8sB/mU+sUV4G7ws8xtYMGdQnxbeIfLjyO9TZZRJdztBGhlmbI5x1qcY4TG5hBkIKGnc28i7nXxaugu88w== +protocols@^1.1.0, protocols@^1.4.0: + version "1.4.7" + resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32" + integrity sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg== + proxy-addr@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.4.tgz#ecfc733bf22ff8c6f407fa275327b9ab67e48b93" @@ -20319,7 +20700,7 @@ qs@6.5.1: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" integrity sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A== -qs@6.5.2, qs@^6.0.2, qs@^6.4.0, qs@^6.5.1, qs@^6.5.2, qs@~6.5.1, qs@~6.5.2: +qs@6.5.2, qs@^6.4.0, qs@^6.5.1, qs@^6.5.2, qs@~6.5.1, qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== @@ -20422,6 +20803,11 @@ ramda@^0.21.0: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" integrity sha1-oAGr7bP/YQd9T/HVd9RN536NCjU= +ramda@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" + integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== + ramda@^0.26.1: version "0.26.1" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" @@ -20820,6 +21206,14 @@ react-input-autosize@^2.1.2, react-input-autosize@^2.2.1: dependencies: prop-types "^15.5.8" +react-input-range@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/react-input-range/-/react-input-range-1.3.0.tgz#f96d001631ab817417f1e26d8f9f9684b4827f59" + integrity sha1-+W0AFjGrgXQX8eJtj5+WhLSCf1k= + dependencies: + autobind-decorator "^1.3.4" + prop-types "^15.5.8" + react-inspector@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-2.3.0.tgz#fc9c1d38ab687fc0d190dcaf133ae40158968fc8" @@ -20891,11 +21285,12 @@ react-markdown-renderer@^1.4.0: prop-types "^15.5.10" remarkable "^1.7.1" -react-markdown@^3.1.4: - version "3.3.0" - resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-3.3.0.tgz#a87cdd822aa9302d6add9687961dd1a82a45d02e" - integrity sha512-eVpQZ8D7NBnms95OhpIrNj6QSuiguSLm27hh1gOeBKZzap6Cr9gl+RXAzpEQpljIqopF6FS0EVkSDCiJEAp77A== +react-markdown@^3.4.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-3.6.0.tgz#29f6aaab5270c8ef0a5e234093a873ec3e01722b" + integrity sha512-TV0wQDHHPCEeKJHWXFfEAKJ8uSEsJ9LgrMERkXx05WV/3q6Ig+59KDNaTmjcoqlCpE/sH5PqqLMh4t0QWKrJ8Q== dependencies: + mdast-add-list-metadata "1.0.1" prop-types "^15.6.1" remark-parse "^5.0.0" unified "^6.1.5" @@ -21576,6 +21971,11 @@ redux-observable@^1.0.0: resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.0.0.tgz#780ff2455493eedcef806616fe286b454fd15d91" integrity sha512-6bXnpqWTBeLaLQjXHyN1giXq4nLxCmv+SUkdmiwBgvmVxvDbdmydvL1Z7DGo0WItyzI/kqXQKiucUuTxnrPRkA== +redux-saga@^0.16.0: + version "0.16.2" + resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-0.16.2.tgz#993662e86bc945d8509ac2b8daba3a8c615cc971" + integrity sha512-iIjKnRThI5sKPEASpUvySemjzwqwI13e3qP7oLub+FycCRDysLSAOwt958niZW6LhxfmS6Qm1BzbU70w/Koc4w== + redux-test-utils@0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/redux-test-utils/-/redux-test-utils-0.2.2.tgz#593213f30173c5908f72315f08b705e1606094fe" @@ -22426,7 +22826,7 @@ rx@^4.1.0: resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I= -rxjs@^5.5.2: +rxjs@^5.5.0, rxjs@^5.5.2: version "5.5.12" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.12.tgz#6fa61b8a77c3d793dbaf270bee2f43f652d741cc" integrity sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw== @@ -22494,11 +22894,6 @@ safefs@^4.0.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -samsam@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" - integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg== - sane@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/sane/-/sane-3.1.0.tgz#995193b7dc1445ef1fe41ddfca2faf9f111854c6" @@ -22694,6 +23089,11 @@ selfsigned@^1.9.1: dependencies: node-forge "0.7.1" +semaphore-async-await@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/semaphore-async-await/-/semaphore-async-await-1.5.1.tgz#857bef5e3644601ca4b9570b87e9df5ca12974fa" + integrity sha1-hXvvXjZEYBykuVcLh+nfXKEpdPo= + semver-diff@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" @@ -23048,18 +23448,18 @@ simplify-js@^1.2.1: resolved "https://registry.yarnpkg.com/simplify-js/-/simplify-js-1.2.3.tgz#a3422c1b9884d60421345eb44d2b872662df27f5" integrity sha512-0IkEqs+5c5vROkHaifGfbqHf5tYDcsTBy6oJPRbFCSwp2uzEr+PpH3dNP7wD8O3d7zdUCjLVq1/xHkwA/JjlFA== -sinon@^5.0.7: - version "5.0.7" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-5.0.7.tgz#3bded6a73613ccc9e512e20246ced69a27c27dab" - integrity sha512-GvNLrwpvLZ8jIMZBUhHGUZDq5wlUdceJWyHvZDmqBxnjazpxY1L0FNbGBX6VpcOEoQ8Q4XMWFzm2myJMvx+VjA== +sinon@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-7.2.2.tgz#388ecabd42fa93c592bfc71d35a70894d5a0ca07" + integrity sha512-WLagdMHiEsrRmee3jr6IIDntOF4kbI6N2pfbi8wkv50qaUQcBglkzkjtoOEbeJ2vf1EsrHhLI+5Ny8//WHdMoA== dependencies: - "@sinonjs/formatio" "^2.0.0" - diff "^3.1.0" - lodash.get "^4.4.2" - lolex "^2.2.0" - nise "^1.2.0" - supports-color "^5.1.0" - type-detect "^4.0.5" + "@sinonjs/commons" "^1.2.0" + "@sinonjs/formatio" "^3.1.0" + "@sinonjs/samsam" "^3.0.2" + diff "^3.5.0" + lolex "^3.0.0" + nise "^1.4.7" + supports-color "^5.5.0" sisteransi@^1.0.0: version "1.0.0" @@ -23567,6 +23967,13 @@ static-module@^1.1.0: static-eval "~0.2.0" through2 "~0.4.1" +stats-lite@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/stats-lite/-/stats-lite-2.2.0.tgz#278a5571fa1d2e8b1691295dccc0235282393bbf" + integrity sha512-/Kz55rgUIv2KP2MKphwYT/NCuSfAlbbMRv2ZWw7wyXayu230zdtzhxxuXXcvsc6EmmhS8bSJl3uS1wmMHFumbA== + dependencies: + isnumber "~1.0.0" + "statuses@>= 1.3.1 < 2", statuses@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" @@ -23688,6 +24095,11 @@ string-similarity@1.2.0: dependencies: lodash "^4.13.1" +string-similarity@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-2.0.0.tgz#c16d8fc7e7c8dce706742c87adc482dbdb7030bb" + integrity sha512-62FBZrVXV5cI23bQ9L49Y4d9u9yaH61JhAwLyUFUzQbHDjdihxdfCwIherg+vylR/s4ucCddK8iKSEO7kinffQ== + string-template@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" @@ -24447,6 +24859,20 @@ textextensions@2: resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.2.0.tgz#38ac676151285b658654581987a0ce1a4490d286" integrity sha512-j5EMxnryTvKxwH2Cq+Pb43tsf6sdEgw6Pdwxk83mPaq0ToeFJt6WE4J3s5BqY7vmjlLgkgXvhtXUxo80FyBhCA== +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY= + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.0" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839" + integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk= + dependencies: + any-promise "^1.0.0" + thread-loader@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/thread-loader/-/thread-loader-2.1.2.tgz#f585dd38e852c7f9cded5d092992108148f5eb30" @@ -24456,6 +24882,15 @@ thread-loader@^2.1.2: loader-utils "^1.1.0" neo-async "^2.6.0" +thriftrw@^3.5.0: + version "3.11.3" + resolved "https://registry.yarnpkg.com/thriftrw/-/thriftrw-3.11.3.tgz#2cef6b4d089b7ba6275198b86582881582907d45" + integrity sha512-mnte80Go5MCfYyOQ9nk6SljaEicCXlwLchupHR+/zlx0MKzXwAiyt38CHjLZVvKtoyEzirasXuNYtkEjgghqCw== + dependencies: + bufrw "^1.2.1" + error "7.0.2" + long "^2.4.0" + throat@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" @@ -25387,16 +25822,16 @@ type-detect@0.1.1: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822" integrity sha1-C6XsKohWQORw6k6FBZcZANrFiCI= +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + type-detect@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2" integrity sha1-diIXzAbbJY7EiQihKY6LlRIejqI= -type-detect@^4.0.5, type-detect@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - type-is@~1.6.15, type-is@~1.6.16: version "1.6.16" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194" @@ -25434,7 +25869,7 @@ typescript-fsa@^2.0.0, typescript-fsa@^2.5.0: resolved "https://registry.yarnpkg.com/typescript-fsa/-/typescript-fsa-2.5.0.tgz#1baec01b5e8f5f34c322679d1327016e9e294faf" integrity sha1-G67AG16PXzTDImedEycBbp4pT68= -typescript@^3.3.3333, typescript@~3.4.3: +typescript@^3.3.3333, typescript@~3.0.3, typescript@~3.3.3333, typescript@~3.4.3: version "3.3.3333" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.3.3333.tgz#171b2c5af66c59e9431199117a3bcadc66fdcfd6" integrity sha512-JjSKsAfuHBE/fB2oZ8NxtRTk5iGcg6hkYXMnZ3Wc+b2RSqejEqTaem11mHASMnFilHrax3sLK0GDzcJrekZYLw== @@ -25700,6 +26135,11 @@ unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.1.tgz#3ccbdc53679eed6ecf3777dd7f5e3229c1b6aa3c" integrity sha1-PMvcU2ee7W7PN3fdf14yKcG2qjw= +unist-util-visit-parents@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-1.1.2.tgz#f6e3afee8bdbf961c0e6f028ea3c0480028c3d06" + integrity sha512-yvo+MMLjEwdc3RhhPYSximset7rwjMrdt9E41Smmvg25UQIenzrN83cRnF1JMzoMi9zZOQeYXHSDf7p+IQkW3Q== + unist-util-visit@^1.1.0, unist-util-visit@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.3.0.tgz#41ca7c82981fd1ce6c762aac397fc24e35711444" @@ -25986,7 +26426,7 @@ uuid@^3.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" integrity sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g== -uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2: +uuid@^3.0.1, uuid@^3.1.0, uuid@^3.2.1, uuid@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== @@ -26512,6 +26952,50 @@ void-elements@^2.0.0, void-elements@^2.0.1: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= +vscode-jsonrpc@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-3.6.2.tgz#3b5eef691159a15556ecc500e9a8a0dd143470c8" + integrity sha512-T24Jb5V48e4VgYliUXMnZ379ItbrXgOimweKaJshD84z+8q7ZOZjJan0MeDe+Ugb+uqERDVV8SBmemaGMSMugA== + +vscode-jsonrpc@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz#a7bf74ef3254d0a0c272fab15c82128e378b3be9" + integrity sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg== + +vscode-languageserver-protocol@3.14.1, vscode-languageserver-protocol@^3.10.3: + version "3.14.1" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.14.1.tgz#b8aab6afae2849c84a8983d39a1cf742417afe2f" + integrity sha512-IL66BLb2g20uIKog5Y2dQ0IiigW0XKrvmWiOvc0yXw80z3tMEzEnHjaGAb3ENuU7MnQqgnYJ1Cl2l9RvNgDi4g== + dependencies: + vscode-jsonrpc "^4.0.0" + vscode-languageserver-types "3.14.0" + +vscode-languageserver-types@3.14.0, vscode-languageserver-types@^3.0.3, vscode-languageserver-types@^3.10.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.14.0.tgz#d3b5952246d30e5241592b6dde8280e03942e743" + integrity sha512-lTmS6AlAlMHOvPQemVwo3CezxBp0sNB95KNPkqp3Nxd5VFEnuG1ByM0zlRWos0zjO3ZWtkvhal0COgiV1xIA4A== + +vscode-languageserver@^4.2.1: + version "4.4.2" + resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-4.4.2.tgz#600ae9cc7a6ff1e84d93c7807840c2cb5b22821b" + integrity sha512-61y8Raevi9EigDgg9NelvT9cUAohiEbUl1LOwQQgOCAaNX62yKny/ddi0uC+FUTm4CzsjhBu+06R+vYgfCYReA== + dependencies: + vscode-languageserver-protocol "^3.10.3" + vscode-uri "^1.0.5" + +vscode-languageserver@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-5.2.1.tgz#0d2feddd33f92aadf5da32450df498d52f6f14eb" + integrity sha512-GuayqdKZqAwwaCUjDvMTAVRPJOp/SLON3mJ07eGsx/Iq9HjRymhKWztX41rISqDKhHVVyFM+IywICyZDla6U3A== + dependencies: + vscode-languageserver-protocol "3.14.1" + vscode-uri "^1.0.6" + +vscode-uri@^1.0.5, vscode-uri@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d" + integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww== + vt-pbf@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/vt-pbf/-/vt-pbf-3.1.1.tgz#b0f627e39a10ce91d943b898ed2363d21899fb82" @@ -27244,6 +27728,11 @@ xmlhttprequest-ssl@~1.5.4: resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e" integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4= +xorshift@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/xorshift/-/xorshift-0.2.1.tgz#fcd82267e9351c13f0fb9c73307f25331d29c63a" + integrity sha1-/NgiZ+k1HBPw+5xzMH8lMx0pxjo= + xpath.js@>=0.0.3: version "1.1.0" resolved "https://registry.yarnpkg.com/xpath.js/-/xpath.js-1.1.0.tgz#3816a44ed4bb352091083d002a383dd5104a5ff1"