From 05e731a0405a5b1f14cf5e07d1420c2e0fc7a56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Tue, 23 Feb 2021 10:23:45 +0100 Subject: [PATCH] WIP Switch to codemirror --- .editorconfig | 15 ++ jupyterlab_pullrequests/base.py | 4 +- jupyterlab_pullrequests/handlers.py | 4 +- jupyterlab_pullrequests/managers/github.py | 30 +-- jupyterlab_pullrequests/managers/gitlab.py | 34 ++- jupyterlab_pullrequests/managers/utils.py | 12 + .../{test_github_unit.py => test_github.py} | 2 +- .../tests/test_manager_utils.py | 19 ++ package.json | 12 +- src/components/PullRequestPanel.tsx | 239 ++++++++++------- src/components/PullRequestToolbar.tsx | 45 ++-- src/components/browser/PullRequestBrowser.tsx | 38 +-- .../browser/PullRequestBrowserFileItem.tsx | 4 +- .../browser/PullRequestBrowserItem.tsx | 207 +++------------ src/components/browser/PullRequestItem.tsx | 148 +++++++++++ src/components/diff/CommentThread.ts | 138 ++++++++++ src/components/diff/NBDiff.tsx | 26 +- src/components/diff/PlainDiffComponent.tsx | 14 +- .../diff/PullRequestCommentThread.tsx | 12 +- src/components/diff/notebook.ts | 241 +++++++++++++++++ src/components/diff/plaintext.ts | 28 ++ .../tab/PullRequestDescriptionTab.tsx | 10 +- src/components/tab/PullRequestFileTab.tsx | 244 ++++++++++++------ src/components/tab/PullRequestTabWidget.tsx | 58 ----- src/index.ts | 44 ---- src/index.tsx | 138 ++++++++++ src/models.tsx | 171 ++++-------- src/tests/testutils.ts | 4 +- src/tokens.ts | 90 +++++++ src/utils.ts | 23 +- style/difftext.css | 119 +++++++++ style/index.css | 2 + yarn.lock | 9 +- 33 files changed, 1510 insertions(+), 674 deletions(-) create mode 100644 .editorconfig create mode 100644 jupyterlab_pullrequests/managers/utils.py rename jupyterlab_pullrequests/tests/{test_github_unit.py => test_github.py} (99%) create mode 100644 jupyterlab_pullrequests/tests/test_manager_utils.py create mode 100644 src/components/browser/PullRequestItem.tsx create mode 100644 src/components/diff/CommentThread.ts create mode 100644 src/components/diff/notebook.ts create mode 100644 src/components/diff/plaintext.ts delete mode 100644 src/components/tab/PullRequestTabWidget.tsx delete mode 100644 src/index.ts create mode 100644 src/index.tsx create mode 100644 src/tokens.ts create mode 100644 style/difftext.css diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4d9b91b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false + +[*.{css,json,ts,tsx}] +indent_size = 2 diff --git a/jupyterlab_pullrequests/base.py b/jupyterlab_pullrequests/base.py index 1a1a36f..59bce40 100644 --- a/jupyterlab_pullrequests/base.py +++ b/jupyterlab_pullrequests/base.py @@ -8,12 +8,12 @@ class PRCommentReply(NamedTuple): text: str - in_reply_to: str + inReplyTo: str class PRCommentNew(NamedTuple): text: str - commit_id: str + commitId: str filename: str position: int diff --git a/jupyterlab_pullrequests/handlers.py b/jupyterlab_pullrequests/handlers.py index d576e2f..1731684 100644 --- a/jupyterlab_pullrequests/handlers.py +++ b/jupyterlab_pullrequests/handlers.py @@ -161,8 +161,8 @@ class PullRequestsFileNBDiffHandler(PullRequestsAPIHandler): async def post(self): data = get_body_value(self) try: - prev_content = data["prev_content"] - curr_content = data["curr_content"] + prev_content = data["previousContent"] + curr_content = data["currentContent"] except KeyError as e: get_logger().error(f"Missing key in POST request.", exc_info=e) raise tornado.web.HTTPError( diff --git a/jupyterlab_pullrequests/managers/github.py b/jupyterlab_pullrequests/managers/github.py index a52addd..e661d04 100644 --- a/jupyterlab_pullrequests/managers/github.py +++ b/jupyterlab_pullrequests/managers/github.py @@ -56,8 +56,8 @@ async def list_prs(self, username: str, pr_filter: str) -> List[Dict[str, str]]: "id": result["pull_request"]["url"], "title": result["title"], "body": result["body"], - "internal_id": result["id"], - "url": result["html_url"], + "internalId": result["id"], + "link": result["html_url"], } ) @@ -78,8 +78,6 @@ async def list_files(self, pr_id: str) -> List[Dict[str, str]]: { "name": result["filename"], "status": result["status"], - "additions": result["additions"], - "deletions": result["deletions"], } ) @@ -101,7 +99,7 @@ async def get_pr_links(self, pr_id: str, filename: str) -> Dict[str, str]: {"ref": data["head"]["ref"]}, ) commit_id = data["head"]["sha"] - return {"base_url": base_url, "head_url": head_url, "commit_id": commit_id} + return {"baseUrl": base_url, "headUrl": head_url, "commitId": commit_id} async def validate_pr_link(self, link: str): try: @@ -124,16 +122,16 @@ async def get_file_content(self, pr_id: str, filename: str) -> Dict[str, str]: links = await self.get_pr_links(pr_id, filename) - base_raw_url = await self.validate_pr_link(links["base_url"]) - head_raw_url = await self.validate_pr_link(links["head_url"]) + base_raw_url = await self.validate_pr_link(links["baseUrl"]) + head_raw_url = await self.validate_pr_link(links["headUrl"]) base_content = await self.get_link_content(base_raw_url) head_content = await self.get_link_content(head_raw_url) return { - "base_content": base_content, - "head_content": head_content, - "commit_id": links["commit_id"], + "baseContent": base_content, + "headContent": head_content, + "commitId": links["commitId"], } # ----------------------------------------------------------------------------- @@ -143,14 +141,14 @@ async def get_file_content(self, pr_id: str, filename: str) -> Dict[str, str]: def file_comment_response(self, result: Dict[str, str]) -> Dict[str, str]: data = { "id": result["id"], - "line_number": result["position"], + "lineNumber": result["position"], "text": result["body"], - "updated_at": result["updated_at"], - "user_name": result["user"]["login"], - "user_pic": result["user"]["avatar_url"], + "updatedAt": result["updated_at"], + "userName": result["user"]["login"], + "userPic": result["user"]["avatar_url"], } if "in_reply_to_id" in result: - data["in_reply_to_id"] = result["in_reply_to_id"] + data["inReplyToId"] = result["in_reply_to_id"] return data async def get_file_comments( @@ -174,7 +172,7 @@ async def post_file_comment( else: body = { "body": body.text, - "commit_id": body.commit_id, + "commit_id": body.commitId, "path": body.filename, "position": body.position, } diff --git a/jupyterlab_pullrequests/managers/gitlab.py b/jupyterlab_pullrequests/managers/gitlab.py index a48b18c..8434067 100644 --- a/jupyterlab_pullrequests/managers/gitlab.py +++ b/jupyterlab_pullrequests/managers/gitlab.py @@ -64,8 +64,8 @@ async def list_prs(self, username: str, pr_filter: str) -> List[Dict[str, str]]: "id": url, "title": result["title"], "body": result["description"], - "internal_id": result["id"], - "url": result["web_url"], + "internalId": result["id"], + "link": result["web_url"], } ) @@ -94,8 +94,6 @@ async def list_files(self, pr_id: str) -> List[Dict[str, str]]: { "name": result["new_path"], "status": status, - "additions": 0, - "deletions": 0, } ) @@ -131,7 +129,7 @@ async def get_pr_links(self, pr_id: str, filename: str) -> Dict[str, str]: {"ref": data["source_branch"]}, ) commit_id = data["diff_refs"]["head_sha"] - return {"base_url": base_url, "head_url": head_url, "commit_id": commit_id} + return {"baseUrl": base_url, "headUrl": head_url, "commitId": commit_id} async def get_link_content(self, file_url: str): try: @@ -143,13 +141,13 @@ async def get_file_content(self, pr_id: str, filename: str) -> Dict[str, str]: links = await self.get_pr_links(pr_id, filename) - base_content = await self.get_link_content(links["base_url"]) - head_content = await self.get_link_content(links["head_url"]) + base_content = await self.get_link_content(links["baseUrl"]) + head_content = await self.get_link_content(links["headUrl"]) return { - "base_content": base_content, - "head_content": head_content, - "commit_id": links["commit_id"], + "baseContent": base_content, + "headContent": head_content, + "commitId": links["commitId"], } # ----------------------------------------------------------------------------- @@ -159,14 +157,14 @@ async def get_file_content(self, pr_id: str, filename: str) -> Dict[str, str]: def file_comment_response(self, result: Dict[str, str]) -> Dict[str, str]: data = { "id": result["id"], - "line_number": result["position"]["new_line"], + "lineNumber": result["position"]["new_line"], "text": result["body"], - "updated_at": result["updated_at"], - "user_name": result["author"]["username"], - "user_pic": result["author"]["avatar_url"], + "updatedAt": result["updated_at"], + "userName": result["author"]["username"], + "userPic": result["author"]["avatar_url"], } if "in_reply_to_id" in result: - data["in_reply_to_id"] = result["in_reply_to_id"] + data["inReplyToId"] = result["in_reply_to_id"] return data async def get_file_comments( @@ -190,13 +188,13 @@ async def post_file_comment( ): if isinstance(body, PRCommentReply): body = {"body": body.text} - # git_url = url_path_join(pr_id, "discussions", "1", "notes") - # response = await self._call_gitlab(git_url, method="POST", body=body) + git_url = url_path_join(pr_id, "discussions", "1", "notes") + response = await self._call_gitlab(git_url, method="POST", body=body) return self.file_comment_response(response) else: body = { "body": body.text, - "commit_id": body.commit_id, + "commitId": body.commit_id, "path": body.filename, "position": {"position_type": "text", "new_line": body.position}, } diff --git a/jupyterlab_pullrequests/managers/utils.py b/jupyterlab_pullrequests/managers/utils.py new file mode 100644 index 0000000..d0caaf7 --- /dev/null +++ b/jupyterlab_pullrequests/managers/utils.py @@ -0,0 +1,12 @@ +import re + +UPPER_CASE = re.compile(r"(? str: + first, *rest = name.split("_") + return "".join([first.lower(), *map(str.title, rest)]) + + +def camel_to_snake_case(name: str) -> str: + return UPPER_CASE.sub("_", name).lower() diff --git a/jupyterlab_pullrequests/tests/test_github_unit.py b/jupyterlab_pullrequests/tests/test_github.py similarity index 99% rename from jupyterlab_pullrequests/tests/test_github_unit.py rename to jupyterlab_pullrequests/tests/test_github.py index f352844..c59631e 100644 --- a/jupyterlab_pullrequests/tests/test_github_unit.py +++ b/jupyterlab_pullrequests/tests/test_github.py @@ -112,7 +112,7 @@ async def test_call(self, mock_call_github): "https://api.github.com/repos/octocat/repo/pulls/1/files" ) assert result == [ - {"name": "README.md", "status": "added", "additions": 1, "deletions": 0} + {"name": "README.md", "status": "added"} ] diff --git a/jupyterlab_pullrequests/tests/test_manager_utils.py b/jupyterlab_pullrequests/tests/test_manager_utils.py new file mode 100644 index 0000000..5dc66f3 --- /dev/null +++ b/jupyterlab_pullrequests/tests/test_manager_utils.py @@ -0,0 +1,19 @@ +import pytest + +from jupyterlab_pullrequests.managers.utils import snake_to_camel_case, camel_to_snake_case + +TO_BE_TESTED = [ + ("banana", "banana"), + ("banana_split", "bananaSplit"), + ("the_famous_banana_split", "theFamousBananaSplit"), +] + + +@pytest.mark.parametrize("input_, expected", TO_BE_TESTED) +def test_snake_to_camel_case(input_, expected): + assert snake_to_camel_case(input_) == expected + + +@pytest.mark.parametrize("expected, input_", TO_BE_TESTED) +def test_camel_to_snake_case(expected, input_): + assert camel_to_snake_case(input_) == expected diff --git a/package.json b/package.json index 472ea14..dac4679 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "scripts": { "build": "jlpm run build:lib", "build:labextension": "cd jupyterlab_pullrequests && rimraf labextension && mkdirp labextension && cd labextension && npm pack ../..", - "build:lib": "webpack && tsc", + "build:lib": "tsc", "build:all": "jlpm run build:labextension", "clean": "jlpm run clean:lib", "clean:lib": "rimraf lib tsconfig.tsbuildinfo", @@ -44,14 +44,20 @@ }, "dependencies": { "@jupyterlab/application": "^2.0.0", + "@jupyterlab/apputils": "^2.0.0", + "@jupyterlab/codemirror": "^2.0.0", "@jupyterlab/coreutils": "^4.0.0", "@jupyterlab/docregistry": "^2.0.0", "@jupyterlab/filebrowser": "^2.0.0", "@jupyterlab/git": "0.21.0 - 0.30.0", "@jupyterlab/mainmenu": "^2.0.0", + "@jupyterlab/nbformat": "^2.0.0", "@jupyterlab/rendermime": "^2.0.0", "@jupyterlab/services": "^5.0.0", + "@jupyterlab/settingregistry": "^2.0.0", "@jupyterlab/ui-components": "^2.0.0", + "@lumino/commands": "^1.12.0", + "@lumino/widgets": "^1.16.0", "base64-js": "^1.3.0", "json-source-map": "^0.4.0", "moment": "^2.24.0", @@ -61,7 +67,8 @@ "react-dom": "~16.9.0", "react-monaco-editor": "^0.26.2", "react-resize-detector": "^4.2.0", - "react-spinners": "0.5.12" + "react-spinners": "0.5.12", + "react-window": "^1.8.5" }, "devDependencies": { "@babel/core": "^7.5.0", @@ -78,6 +85,7 @@ "@types/react": "~16.8.19", "@types/react-dom": "~16.0.5", "@types/react-resize-detector": "^4.0.1", + "@types/react-window": "^1.8.0", "@typescript-eslint/eslint-plugin": "^2.25.0", "@typescript-eslint/parser": "^2.25.0", "d3-color": "^1.2.8", diff --git a/src/components/PullRequestPanel.tsx b/src/components/PullRequestPanel.tsx index b59b750..38216e5 100644 --- a/src/components/PullRequestPanel.tsx +++ b/src/components/PullRequestPanel.tsx @@ -1,106 +1,159 @@ -import React from 'react'; -import { JupyterFrontEnd } from '@jupyterlab/application'; -import { - IThemeManager, - MainAreaWidget, - ReactWidget, - Toolbar -} from '@jupyterlab/apputils'; -import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; -import { PanelLayout, Widget } from '@lumino/widgets'; -import { PullRequestFileModel, PullRequestModel } from '../models'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { CommandRegistry } from '@lumino/commands'; +import React, { useEffect, useState } from 'react'; +import { BeatLoader } from 'react-spinners'; +import { IPullRequest, IPullRequestGroup } from '../tokens'; +import { requestAPI } from '../utils'; import { PullRequestBrowser } from './browser/PullRequestBrowser'; import { PullRequestToolbar } from './PullRequestToolbar'; -import { PullRequestTabWidget } from './tab/PullRequestTabWidget'; -import { PullRequestDescriptionTab } from './tab/PullRequestDescriptionTab'; -export class PullRequestPanel extends Widget { - private _app: JupyterFrontEnd; - private _themeManager: IThemeManager; - private _renderMime: IRenderMimeRegistry; - private _toolbar: Toolbar; - private _browser: Widget; - private _tabs: Widget[]; +export interface IPullRequestPanelProps { + /** + * Jupyter Front End Commands Registry + */ + commands: CommandRegistry; + docRegistry: DocumentRegistry; +} - constructor( - app: JupyterFrontEnd, - themeManager: IThemeManager, - renderMime: IRenderMimeRegistry - ) { - super(); - this.addClass('jp-PullRequestPanel'); - this.layout = new PanelLayout(); +type Filter = 'created' | 'assigned'; - this._app = app; - this._themeManager = themeManager; - this._renderMime = renderMime; - this._tabs = []; - this._browser = ReactWidget.create( - - ); - this._toolbar = new PullRequestToolbar(this.update.bind(this)); +interface IFilter { + name: string; + filter: Filter; +} - (this.layout as PanelLayout).addWidget(this._toolbar); - this._toolbar.activate(); - (this.layout as PanelLayout).addWidget(this._browser); - } +async function fetchPullRequests( + filters: IFilter[] +): Promise { + return Promise.all( + filters.map( + async (filter: IFilter): Promise => { + try { + const pullRequests = await requestAPI( + 'pullrequests/prs/user?filter=' + filter.filter, + 'GET' + ); + return { + name: filter.name, + pullRequests + }; + } catch (err) { + let error = 'Unknown Error'; + if (err.response?.status && err.message) { + error = `${err.response.status} (${err.message})`; + } + return { + name: filter.name, + pullRequests: [], + error + }; + } + } + ) + ); +} - // Show tab window for specific PR - // FIXME transform to command - showTab = async ( - data: PullRequestFileModel | PullRequestModel - ): Promise => { - let tab = this.getTab(data.id); - if (tab === null) { - if (data instanceof PullRequestFileModel) { - tab = new MainAreaWidget({ - content: new PullRequestTabWidget( - data, - this._themeManager, - this._renderMime - ) - }); - tab.title.label = data.name; - } else { - tab = new MainAreaWidget({ - content: new PullRequestDescriptionTab({ - pr: data, - renderMimeRegistry: this._renderMime - }) - }); - tab.title.label = data.title; +export function PullRequestPanel(props: IPullRequestPanelProps): JSX.Element { + const [pullRequests, setPullRequests] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const refreshPullRequests = (): void => { + setIsLoading(true); + fetchPullRequests([ + { + name: 'Created by Me', + filter: 'created' + }, + { + name: 'Assigned to Me', + filter: 'assigned' } - tab.id = data.id; - this._tabs.push(tab); - } - if (!tab.isAttached) { - this._app.shell.add(tab, 'main'); - } - tab.update(); - this._app.shell.activateById(tab.id); + ]) + .then(data => { + setPullRequests(data); + setIsLoading(false); + }) + .catch(reason => { + console.error('Failed to fetch pull requests', reason); + setPullRequests([]); + setIsLoading(false); + }); }; - private getTab(id: string): Widget | null { - for (const tab of this._tabs) { - if (tab.id.toString() === id.toString()) { - return tab; - } - } - return null; - } + useEffect(refreshPullRequests, []); + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} +
+ ); + + // // Show tab window for specific PR + // // FIXME transform to command + // showTab = async ( + // data: PullRequestFileModel | PullRequestModel + // ): Promise => { + // let tab = this.getTab(data.id); + // if (tab === null) { + // if (data instanceof PullRequestFileModel) { + // tab = new MainAreaWidget({ + // content: new PullRequestTabWidget( + // data, + // this._themeManager, + // this._renderMime + // ) + // }); + // tab.title.label = data.name; + // } else { + // tab = new MainAreaWidget({ + // content: new PullRequestDescriptionTab({ + // pr: data, + // renderMimeRegistry: this._renderMime + // }) + // }); + // tab.title.label = data.title; + // } + // tab.id = data.id; + // this._tabs.push(tab); + // } + // if (!tab.isAttached) { + // this._app.shell.add(tab, 'main'); + // } + // tab.update(); + // this._app.shell.activateById(tab.id); + // }; + + // private getTab(id: string): Widget | null { + // for (const tab of this._tabs) { + // if (tab.id.toString() === id.toString()) { + // return tab; + // } + // } + // return null; + // } - getApp() { - return this._app; - } + // getApp() { + // return this._app; + // } - onUpdateRequest(): void { - // FIXME - it is not working as expected - this._browser.update(); - for (const tab of this._tabs) { - tab.update(); - } - } + // onUpdateRequest(): void { + // // FIXME - it is not working as expected + // this._browser.update(); + // for (const tab of this._tabs) { + // tab.update(); + // } + // } } diff --git a/src/components/PullRequestToolbar.tsx b/src/components/PullRequestToolbar.tsx index 40f16da..0961436 100644 --- a/src/components/PullRequestToolbar.tsx +++ b/src/components/PullRequestToolbar.tsx @@ -1,27 +1,26 @@ +import React from 'react'; import { refreshIcon } from '@jupyterlab/ui-components'; -import { Toolbar, ToolbarButton } from '@jupyterlab/apputils'; -import { Widget } from '@lumino/widgets'; +import { ActionButton } from '@jupyterlab/git/lib/components/ActionButton'; -export class PullRequestToolbar extends Toolbar { - constructor(onUpdate: () => void) { - super(); - this.addClass('jp-PullRequestToolbar'); - - // Add toolbar header - const widget: Widget = new Widget(); - const title = document.createElement('h2'); - title.innerText = 'Pull Requests'; - widget.addClass('jp-PullRequestToolbarHeader'); - widget.node.appendChild(title); - this.addItem('Widget', widget); +export interface IPullRequestToolbarProps { + /** + * Refresh button callback + */ + onRefresh: () => void; +} - // Add toolbar refresh button - const openRefreshButton = new ToolbarButton({ - onClick: onUpdate, - icon: refreshIcon, - tooltip: 'Refresh' - }); - openRefreshButton.addClass('jp-PullRequestToolbarItem'); - this.addItem('Refresh', openRefreshButton); - } +export function PullRequestToolbar( + props: IPullRequestToolbarProps +): JSX.Element { + return ( +
+

Pull Requests

+ + +
+ ); } diff --git a/src/components/browser/PullRequestBrowser.tsx b/src/components/browser/PullRequestBrowser.tsx index deb218b..0927927 100644 --- a/src/components/browser/PullRequestBrowser.tsx +++ b/src/components/browser/PullRequestBrowser.tsx @@ -1,31 +1,39 @@ -import * as React from 'react'; import { DocumentRegistry } from '@jupyterlab/docregistry'; -import { PullRequestFileModel, PullRequestModel } from '../../models'; +import { CommandRegistry } from '@lumino/commands'; +import * as React from 'react'; +import { IPullRequestGroup } from '../../tokens'; import { PullRequestBrowserItem } from './PullRequestBrowserItem'; export interface IPullRequestBrowserProps { + /** + * Jupyter Front End Commands Registry + */ + commands: CommandRegistry; docRegistry: DocumentRegistry; - showTab: (data: PullRequestFileModel | PullRequestModel) => Promise; + /** + * Groups of Pull Request Lists + */ + prGroups: IPullRequestGroup[]; } +/** + * Display the Pull Request Lists + * @param props Component properties + */ export function PullRequestBrowser( props: IPullRequestBrowserProps ): JSX.Element { return (
    - - + {props.prGroups.map(group => ( + + ))}
); diff --git a/src/components/browser/PullRequestBrowserFileItem.tsx b/src/components/browser/PullRequestBrowserFileItem.tsx index 9252501..df48cd8 100644 --- a/src/components/browser/PullRequestBrowserFileItem.tsx +++ b/src/components/browser/PullRequestBrowserFileItem.tsx @@ -1,9 +1,9 @@ import { FilePath } from '@jupyterlab/git/lib/components/FilePath'; import * as React from 'react'; -import { PullRequestFileModel } from '../../models'; +import { IFile } from '../../tokens'; export interface IPullRequestBrowserFileItemProps { - file: PullRequestFileModel; + file: IFile; } export function PullRequestBrowserFileItem( diff --git a/src/components/browser/PullRequestBrowserItem.tsx b/src/components/browser/PullRequestBrowserItem.tsx index d5c03e4..36a2888 100644 --- a/src/components/browser/PullRequestBrowserItem.tsx +++ b/src/components/browser/PullRequestBrowserItem.tsx @@ -1,175 +1,48 @@ -import { - caretDownIcon, - caretUpIcon, - linkIcon -} from '@jupyterlab/ui-components'; import { DocumentRegistry } from '@jupyterlab/docregistry'; -import { ActionButton } from '@jupyterlab/git/lib/components/ActionButton'; +import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; -import { BeatLoader } from 'react-spinners'; -import { PullRequestFileModel, PullRequestModel } from '../../models'; -import { doRequest } from '../../utils'; -import { PullRequestBrowserFileItem } from './PullRequestBrowserFileItem'; - -export interface IPullRequestBrowserItemState { - data: PullRequestModel[]; - isLoading: boolean; - error: string | null; -} +import { IPullRequestGroup } from '../../tokens'; +import { PullRequestItem } from './PullRequestItem'; export interface IPullRequestBrowserItemProps { + /** + * Jupyter Front End Commands Registry + */ + commands: CommandRegistry; docRegistry: DocumentRegistry; - header: string; - filter: string; - showTab: (data: PullRequestFileModel | PullRequestModel) => Promise; + /** + * Group of Pull Request Lists + */ + group: IPullRequestGroup; } -export class PullRequestBrowserItem extends React.Component< - IPullRequestBrowserItemProps, - IPullRequestBrowserItemState -> { - constructor(props: IPullRequestBrowserItemProps) { - super(props); - this.state = { data: [], isLoading: true, error: null }; - } - - async componentDidMount() { - await this.fetchPRs(); - } - - private async fetchPRs() { - try { - const jsonresults = await doRequest( - 'pullrequests/prs/user?filter=' + this.props.filter, - 'GET' - ); - const results: PullRequestModel[] = []; - for (const jsonresult of jsonresults) { - results.push( - new PullRequestModel( - jsonresult['id'], - jsonresult['title'], - jsonresult['body'], - jsonresult['url'], - jsonresult['internal_id'], - this.props.docRegistry - ) - ); - } - // render PRs while files load - this.setState({ data: results, isLoading: true, error: null }, () => { - this.fetchFiles(results); - }); - } catch (err) { - let msg = 'Unknown Error'; - if (err.response?.status && err.message) { - msg = `${err.response.status} (${err.message})`; - } - this.setState({ data: [], isLoading: false, error: msg }); - } - } - - private async fetchFiles(items: PullRequestModel[]) { - Promise.all( - items.map(async item => { - await item.getFiles(); - }) - ) - .then(() => { - this.setState({ data: items, isLoading: false, error: null }); - }) - .catch(e => { - const msg = `Get Files Error (${e})`; - this.setState({ data: [], isLoading: false, error: msg }); - }); - } - - // This makes a shallow copy of data[i], the data[i].files are not copied - // If files need to be mutated, will need to restructure props / deep copy - private toggleFilesExpanded( - e: React.MouseEvent, - i: number - ) { - e.stopPropagation(); - const data = [...this.state.data]; - const item = Object.assign({}, data[i]); - item.isExpanded = !item.isExpanded; - data[i] = item; - this.setState({ data }); - } - - private openLink( - e: React.MouseEvent, - link: string - ) { - e.stopPropagation(); - window.open(link, '_blank'); - } - - private showFileTab( - e: React.MouseEvent, - file: PullRequestFileModel - ) { - e.stopPropagation(); - this.props.showTab(file); - } - - render() { - return ( -
  • -
    -

    {this.props.header}

    - -
    - {this.state.error ? ( -

    - - Error Listing Pull Requests: - {' '} - {this.state.error} -

    - ) : ( -
      - {this.state.data.map((result, i) => ( -
      this.props.showTab(result)}> -
    • -

      {result.title}

      -
      - this.openLink(e, result.link)} - title="Open in new tab" - /> - this.toggleFilesExpanded(e, i)} - title={ - result.isExpanded - ? 'Hide modified files' - : 'Show modified files' - } - /> -
      -
    • - {result.isExpanded && ( -
        - {result.files?.map((file, k) => ( -
      • this.showFileTab(e, file)}> - -
      • - ))} -
      - )} -
      - ))} -
    - )} -
  • - ); - } +export function PullRequestBrowserItem( + props: IPullRequestBrowserItemProps +): JSX.Element { + return ( +
  • +
    +

    {props.group.name}

    +
    + {props.group.error ? ( +

    + + Error Listing Pull Requests: + {' '} + {props.group.error} +

    + ) : ( +
      + {props.group.pullRequests.map(pullRequest => ( + + ))} +
    + )} +
  • + ); } diff --git a/src/components/browser/PullRequestItem.tsx b/src/components/browser/PullRequestItem.tsx new file mode 100644 index 0000000..2799cba --- /dev/null +++ b/src/components/browser/PullRequestItem.tsx @@ -0,0 +1,148 @@ +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { ActionButton } from '@jupyterlab/git/lib/components/ActionButton'; +import { + caretDownIcon, + caretUpIcon, + linkIcon +} from '@jupyterlab/ui-components'; +import { CommandRegistry } from '@lumino/commands'; +import React, { useEffect, useState } from 'react'; +import { BeatLoader } from 'react-spinners'; +import { CommandIDs, IFile, IPullRequest } from '../../tokens'; +import { requestAPI } from '../../utils'; +import { PullRequestBrowserFileItem } from './PullRequestBrowserFileItem'; + +export interface IPullRequestItemProps { + /** + * Jupyter Front End Commands Registry + */ + commands: CommandRegistry; + docRegistry: DocumentRegistry; + pullRequest: IPullRequest; +} + +function openLink(link: string): void { + window.open(link, '_blank'); +} + +export function PullRequestItem(props: IPullRequestItemProps): JSX.Element { + const { commands, docRegistry, pullRequest } = props; + const [files, setFiles] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setFiles(null); + setIsExpanded(false); + setIsLoading(false); + setError(null); + }, [props.pullRequest]); + + const fetchFiles = async (): Promise => { + setIsLoading(true); + try { + const results = (await requestAPI( + 'pullrequests/prs/files?id=' + encodeURIComponent(pullRequest.id), + 'GET' + )) as any[]; + setFiles( + results.map( + (rawFile: any): IFile => { + const path = rawFile.name; + return { + ...rawFile, + fileType: + docRegistry.getFileTypesForPath(path)[0] || + DocumentRegistry.defaultTextFileType + }; + } + ) + ); + } finally { + setIsLoading(false); + } + }; + + // This makes a shallow copy of data[i], the data[i].files are not copied + // If files need to be mutated, will need to restructure props / deep copy + const toggleFilesExpanded = (): void => { + if (files === null && !isExpanded) { + setError(null); + fetchFiles() + .then(() => { + setIsExpanded(!isExpanded); + }) + .catch(reason => { + setError(`Failed to get pull request files ${reason}`); + }); + } else { + setIsExpanded(!isExpanded); + } + }; + + return ( +
  • { + commands.execute(CommandIDs.prOpenDescription, { pullRequest } as any); + }} + > +

    {pullRequest.title}

    +
    + { + e.stopPropagation(); + openLink(pullRequest.link); + }} + title="Open in new tab" + /> + { + e.stopPropagation(); + toggleFilesExpanded(); + }} + title={isExpanded ? 'Hide modified files' : 'Show modified files'} + /> +
    + {isLoading ? ( + + ) : ( + isExpanded && + (error ? ( +
    +

    + Error Listing Pull Request Files: +

    + {error} +
    + ) : ( +
      + {files?.map(file => ( +
    • { + e.stopPropagation(); + commands.execute(CommandIDs.prOpenDiff, { + file, + pullRequest + } as any); + }} + > + +
    • + ))} +
    + )) + )} +
  • + ); +} diff --git a/src/components/diff/CommentThread.ts b/src/components/diff/CommentThread.ts new file mode 100644 index 0000000..ef754be --- /dev/null +++ b/src/components/diff/CommentThread.ts @@ -0,0 +1,138 @@ +import { Widget } from '@lumino/widgets'; +import { IComment, IThread } from '../../tokens'; +import moment from 'moment'; + +export interface ICommentThreadProps { + thread: IThread; + handleRemove: () => void; + handleAddComment: (comment: IComment) => void; +} + +export class CommentThread extends Widget { + constructor(props: ICommentThreadProps) { + super({ node: CommentThread.createNode() }); + + // Add event + const buttons = this.node.getElementsByTagName('button'); + buttons[0].addEventListener('click', () => { + this.isExpanded = !this.isExpanded; + }); + + // this._handleAddComment = props.handleAddComment; + // this._handleRemove = props.handleRemove; + this._thread = props.thread; + } + + get inputText(): string { + return this._inputText; + } + set inputText(v: string) { + this._inputText = v; + } + + get isExpanded(): boolean { + return this._isExpanded; + } + set isExpanded(v: boolean) { + this._isExpanded = v; + if (this._isExpanded !== v) { + const header = this.node.getElementsByClassName( + 'jp-PullRequestCommentHeader' + )[0]; + // Clean up + const ps = header.getElementsByTagName('p'); + for (const p of ps) { + p.remove(); + } + if (this._thread.comments.length > 0) { + const firstComment = this._thread.comments[0]; + const p = document.createElement('p'); + p.innerText = `${firstComment.userName}: ${firstComment.text}`; + header.prepend(p); + } + } + } + + get inputShown(): boolean { + return this._inputShown; + } + set inputShown(v: boolean) { + if (this._inputShown === v) { + this._inputShown = v; + } + } + + protected static createCommentNode(comment: IComment): string { + return `
    +
    + +
    +
    +
    +

    ${comment.userName}

    +

    ${moment(comment.updatedAt).fromNow()}

    +
    +

    ${comment.text}

    +
    +
    `; + } + + protected static createNode(): HTMLDivElement { + const div = document.createElement('div'); + div.innerHTML = `
    +
    + +
    +
    `; + return div; + } + + protected static insertInputNode( + container: HTMLElement, + onCommentChanged: (event: Event) => void, + onCancel: () => void, + onSubmit: () => void + ): void { + // Clean up + for (const child of container.children) { + child.remove(); + } + + container.innerHTML = `