diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 725a3a32f..5baafdc58 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -1494,24 +1494,54 @@ async def remote_add(self, path, url, name=DEFAULT_REMOTE_NAME): return response - async def remote_show(self, path): + async def remote_show(self, path, verbose=False): """Handle call to `git remote show` command. Args: path (str): Git repository path - + verbose (bool): true if details are needed, otherwise, false Returns: - List[str]: Known remotes + if not verbose: List[str]: Known remotes + if verbose: List[ { name: str, url: str } ]: Known remotes """ - command = ["git", "remote", "show"] + command = ["git", "remote"] + if verbose: + command.extend(["-v", "show"]) + else: + command.append("show") + code, output, error = await execute(command, cwd=path) response = {"code": code, "command": " ".join(command)} + if code == 0: - response["remotes"] = [r.strip() for r in output.splitlines()] + if verbose: + response["remotes"] = [ + {"name": r.split("\t")[0], "url": r.split("\t")[1][:-7]} + for r in output.splitlines() + if "(push)" in r + ] + else: + response["remotes"] = [r.strip() for r in output.splitlines()] else: response["message"] = error return response + async def remote_remove(self, path, name): + """Handle call to `git remote remove ` command. + Args: + path (str): Git repository path + name (str): Remote name + """ + command = ["git", "remote", "remove", name] + + code, _, error = await execute(command, cwd=path) + response = {"code": code, "command": " ".join(command)} + + if code != 0: + response["message"] = error + + return response + async def ensure_gitignore(self, path): """Handle call to ensure .gitignore file exists and the next append will be on a new line (this means an empty file diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 769f93516..30edbdc5a 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -391,6 +391,37 @@ async def post(self, path: str = ""): self.finish(json.dumps(output)) +class GitRemoteDetailsShowHandler(GitHandler): + """Handler for 'git remote -v'.""" + + @tornado.web.authenticated + async def get(self, path: str = ""): + """GET request handler to retrieve existing remotes.""" + local_path = self.url2localpath(path) + output = await self.git.remote_show(local_path, verbose=True) + if output["code"] == 0: + self.set_status(200) + else: + self.set_status(500) + self.finish(json.dumps(output)) + + +class GitRemoteRemoveHandler(GitHandler): + """Handler for 'git remote remove '.""" + + @tornado.web.authenticated + async def delete(self, path: str = "", name: str = ""): + """DELETE request handler to remove a remote.""" + local_path = self.url2localpath(path) + + output = await self.git.remote_remove(local_path, name) + if output["code"] == 0: + self.set_status(204) + else: + self.set_status(500) + self.finish(json.dumps(output)) + + class GitResetHandler(GitHandler): """ Handler for 'git reset '. @@ -871,6 +902,7 @@ def setup_handlers(web_app): ("/push", GitPushHandler), ("/remote/add", GitRemoteAddHandler), ("/remote/fetch", GitFetchHandler), + ("/remote/show", GitRemoteDetailsShowHandler), ("/reset", GitResetHandler), ("/reset_to_commit", GitResetToCommitHandler), ("/show_prefix", GitShowPrefixHandler), @@ -890,12 +922,23 @@ def setup_handlers(web_app): # add the baseurl to our paths base_url = web_app.settings["base_url"] - git_handlers = [ - (url_path_join(base_url, NAMESPACE + path_regex + endpoint), handler) - for endpoint, handler in handlers_with_path - ] + [ - (url_path_join(base_url, NAMESPACE + endpoint), handler) - for endpoint, handler in handlers - ] + git_handlers = ( + [ + (url_path_join(base_url, NAMESPACE + path_regex + endpoint), handler) + for endpoint, handler in handlers_with_path + ] + + [ + (url_path_join(base_url, NAMESPACE + endpoint), handler) + for endpoint, handler in handlers + ] + + [ + ( + url_path_join( + base_url, NAMESPACE + path_regex + r"/remote/(?P\w+)" + ), + GitRemoteRemoveHandler, + ) + ] + ) web_app.add_handlers(".*", git_handlers) diff --git a/jupyterlab_git/tests/test_remote.py b/jupyterlab_git/tests/test_remote.py index 1ed6b1c07..8e5ccc335 100644 --- a/jupyterlab_git/tests/test_remote.py +++ b/jupyterlab_git/tests/test_remote.py @@ -1,11 +1,11 @@ import json from unittest.mock import patch - +import os import pytest import tornado +from jupyterlab_git.git import Git from jupyterlab_git.handlers import NAMESPACE - from .testutils import assert_http_error, maybe_future @@ -101,3 +101,81 @@ async def test_git_add_remote_failure(mock_execute, jp_fetch, jp_root_dir): mock_execute.assert_called_once_with( ["git", "remote", "add", "origin", url], cwd=str(local_path) ) + + +@patch("jupyterlab_git.git.execute") +async def test_git_remote_show(mock_execute, jp_root_dir): + # Given + local_path = jp_root_dir / "test_path" + mock_execute.return_value = maybe_future( + (0, os.linesep.join(["origin", "test"]), "") + ) + + # When + output = await Git().remote_show(str(local_path), False) + + # Then + command = ["git", "remote", "show"] + mock_execute.assert_called_once_with(command, cwd=str(local_path)) + assert output == { + "code": 0, + "command": " ".join(command), + "remotes": ["origin", "test"], + } + + +@patch("jupyterlab_git.git.execute") +async def test_git_remote_show_verbose(mock_execute, jp_fetch, jp_root_dir): + # Given + local_path = jp_root_dir / "test_path" + url = "http://github.com/myid/myrepository.git" + process_output = os.linesep.join( + [f"origin\t{url} (fetch)", f"origin\t{url} (push)"] + ) + mock_execute.return_value = maybe_future((0, process_output, "")) + + # When + response = await jp_fetch( + NAMESPACE, + local_path.name, + "remote", + "show", + method="GET", + ) + + # Then + command = ["git", "remote", "-v", "show"] + mock_execute.assert_called_once_with(command, cwd=str(local_path)) + + assert response.code == 200 + payload = json.loads(response.body) + assert payload == { + "code": 0, + "command": " ".join(command), + "remotes": [ + {"name": "origin", "url": "http://github.com/myid/myrepository.git"} + ], + } + + +@patch("jupyterlab_git.git.execute") +async def test_git_remote_remove(mock_execute, jp_fetch, jp_root_dir): + # Given + local_path = jp_root_dir / "test_path" + mock_execute.return_value = maybe_future((0, "", "")) + + # When + name = "origin" + response = await jp_fetch( + NAMESPACE, + local_path.name, + "remote", + name, + method="DELETE", + ) + + # Then + command = ["git", "remote", "remove", name] + mock_execute.assert_called_once_with(command, cwd=str(local_path)) + + assert response.code == 204 diff --git a/schema/plugin.json b/schema/plugin.json index ad8528588..d88271822 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -100,7 +100,7 @@ }, { "command": "git:push", - "args": { "force": true } + "args": { "advanced": true } }, { "command": "git:pull" @@ -113,7 +113,7 @@ "command": "git:reset-to-remote" }, { - "command": "git:add-remote" + "command": "git:manage-remote" }, { "command": "git:terminal-command" diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 2bb5f8887..a9ff31925 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -1,7 +1,6 @@ import { JupyterFrontEnd } from '@jupyterlab/application'; import { Dialog, - InputDialog, MainAreaWidget, ReactWidget, showDialog, @@ -47,7 +46,9 @@ import { } from './tokens'; import { GitCredentialsForm } from './widgets/CredentialsBox'; import { discardAllChanges } from './widgets/discardAllChanges'; +import { ManageRemoteDialogue } from './components/ManageRemoteDialogue'; import { CheckboxForm } from './widgets/GitResetToRemoteForm'; +import { AdvancedPushForm } from './widgets/AdvancedPushForm'; export interface IGitCloneArgs { /** @@ -253,42 +254,35 @@ export function addCommands( }); /** Command to add a remote Git repository */ - commands.addCommand(CommandIDs.gitAddRemote, { - label: trans.__('Add Remote Repository'), - caption: trans.__('Add a Git remote repository'), + commands.addCommand(CommandIDs.gitManageRemote, { + label: trans.__('Manage Remote Repositories'), + caption: trans.__('Manage Remote Repositories'), isEnabled: () => gitModel.pathRepository !== null, - execute: async args => { + execute: () => { if (gitModel.pathRepository === null) { console.warn( trans.__('Not in a Git repository. Unable to add a remote.') ); return; } - let url = args['url'] as string; - const name = args['name'] as string; - - if (!url) { - const result = await InputDialog.getText({ - title: trans.__('Add a remote repository'), - placeholder: trans.__('Remote Git repository URL') - }); - if (result.button.accept) { - url = result.value; - } + const widgetId = 'git-dialog-ManageRemote'; + let anchor = document.querySelector(`#${widgetId}`); + if (!anchor) { + anchor = document.createElement('div'); + anchor.id = widgetId; + document.body.appendChild(anchor); } - if (url) { - try { - await gitModel.addRemote(url, name); - } catch (error) { - console.error(error); - showErrorMessage( - trans.__('Error when adding remote repository'), - error - ); - } - } + const dialog = ReactWidget.create( + dialog.dispose()} + /> + ); + + Widget.attach(dialog, anchor); } }); @@ -305,22 +299,44 @@ export function addCommands( /** Add git push command */ commands.addCommand(CommandIDs.gitPush, { label: args => - args.force - ? trans.__('Push to Remote (Force)') + (args['advanced'] as boolean) + ? trans.__('Push to Remote (Advanced)') : trans.__('Push to Remote'), caption: trans.__('Push code to remote repository'), isEnabled: () => gitModel.pathRepository !== null, execute: async args => { - logger.log({ - level: Level.RUNNING, - message: trans.__('Pushing…') - }); try { + let remote; + let force; + + if (args['advanced'] as boolean) { + const result = await showDialog({ + title: trans.__('Please select push options.'), + body: new AdvancedPushForm(trans, gitModel), + buttons: [ + Dialog.cancelButton({ label: trans.__('Cancel') }), + Dialog.okButton({ label: trans.__('Proceed') }) + ] + }); + if (result.button.accept) { + remote = result.value.remoteName; + force = result.value.force; + } else { + return; + } + } + + logger.log({ + level: Level.RUNNING, + message: trans.__('Pushing…') + }); const details = await showGitOperationDialog( gitModel, - args.force ? Operation.ForcePush : Operation.Push, - trans + force ? Operation.ForcePush : Operation.Push, + trans, + (args = { remote }) ); + logger.log({ message: trans.__('Successfully pushed'), level: Level.SUCCESS, @@ -1270,12 +1286,12 @@ export function createGitMenu( CommandIDs.gitPush, CommandIDs.gitPull, CommandIDs.gitResetToRemote, - CommandIDs.gitAddRemote, + CommandIDs.gitManageRemote, CommandIDs.gitTerminalCommand ].forEach(command => { menu.addItem({ command }); if (command === CommandIDs.gitPush) { - menu.addItem({ command, args: { force: true } }); + menu.addItem({ command, args: { advanced: true } }); } if (command === CommandIDs.gitPull) { menu.addItem({ command, args: { force: true } }); @@ -1512,10 +1528,18 @@ export async function showGitOperationDialog( result = await model.pull(authentication); break; case Operation.Push: - result = await model.push(authentication); + result = await model.push( + authentication, + false, + (args as unknown as { remote: string })['remote'] + ); break; case Operation.ForcePush: - result = await model.push(authentication, true); + result = await model.push( + authentication, + true, + (args as unknown as { remote: string })['remote'] + ); break; case Operation.Fetch: result = await model.fetch(authentication); diff --git a/src/components/ManageRemoteDialogue.tsx b/src/components/ManageRemoteDialogue.tsx new file mode 100644 index 000000000..c3eef4348 --- /dev/null +++ b/src/components/ManageRemoteDialogue.tsx @@ -0,0 +1,223 @@ +import * as React from 'react'; +import ClearIcon from '@material-ui/icons/Clear'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import { TranslationBundle } from '@jupyterlab/translation'; +import { showErrorMessage } from '@jupyterlab/apputils'; +import { ActionButton } from './ActionButton'; +import { Git } from '../tokens'; +import { GitExtension } from '../model'; +import { classes } from 'typestyle'; +import { + remoteDialogClass, + remoteDialogInputClass, + existingRemoteWrapperClass, + existingRemoteGridClass, + actionsWrapperClass +} from '../style/ManageRemoteDialog'; +import { + buttonClass, + closeButtonClass, + contentWrapperClass, + createButtonClass, + titleClass, + titleWrapperClass +} from '../style/NewBranchDialog'; +import { trashIcon } from '../style/icons'; + +export interface IManageRemoteDialogueProps { + /** + * The application language translator. + */ + trans: TranslationBundle; + /** + * Warning content. + */ + warningContent?: string; + /** + * Git extension model + */ + model: GitExtension; + /** + * Callback to handle the closing of dialogue + */ + onClose: () => void; +} + +export interface IManageRemoteDialogueState { + /** + * New remote name and url pair + */ + newRemote: Git.IGitRemote; + /** + * List of known remotes + */ + existingRemotes: Git.IGitRemote[] | null; +} + +export class ManageRemoteDialogue extends React.Component< + IManageRemoteDialogueProps, + IManageRemoteDialogueState +> { + constructor(props: IManageRemoteDialogueProps) { + super(props); + this.state = { + newRemote: { + name: '', + url: '' + }, + existingRemotes: null + }; + } + + async componentDidMount(): Promise { + try { + const remotes = await this.props.model.getRemotes(); + this.setState({ existingRemotes: remotes }); + } catch (err) { + console.error(err); + } + } + + render(): JSX.Element { + return ( + +
+

{this.props.trans.__('Manage Remotes')}

+ +
+
+ + + {this.props.warningContent && ( +
+ {this.props.warningContent} +
+ )} + + + { + this._addRemoteButton = btn; + }} + className={classes(buttonClass, createButtonClass)} + type="button" + title={this.props.trans.__('Add Remote')} + value={this.props.trans.__('Add')} + onClick={async () => { + const { name, url } = this.state.newRemote; + try { + await this.props.model.addRemote(url, name); + this._nameInput.value = ''; + this._urlInput.value = ''; + this.setState(prevState => ({ + existingRemotes: [ + ...prevState.existingRemotes, + prevState.newRemote + ], + newRemote: { name: '', url: '' } + })); + } catch (error) { + console.error(error); + showErrorMessage( + this.props.trans.__('Error when adding remote repository'), + error + ); + } + }} + disabled={!this.state.newRemote.name || !this.state.newRemote.url} + /> + + +
+

{this.props.trans.__('Existing Remotes:')}

+ + {this.state.existingRemotes === null ? ( +

Loading remote repositories...

+ ) : this.state.existingRemotes.length > 0 ? ( +
+ {this.state.existingRemotes.map((remote, index) => ( + + {remote.name} + {remote.url} + { + await this.props.model.removeRemote(remote.name); + this.setState({ + existingRemotes: this.state.existingRemotes.filter( + r => r.name !== remote.name + ) + }); + }} + /> + + ))} +
+ ) : ( +

This repository does not have any remote.

+ )} +
+
+
+ ); + } + + private _nameInput: HTMLInputElement; + private _urlInput: HTMLInputElement; + private _addRemoteButton: HTMLInputElement; +} diff --git a/src/model.ts b/src/model.ts index 2f55ebd9c..fbab8ab8f 100644 --- a/src/model.ts +++ b/src/model.ts @@ -453,6 +453,35 @@ export class GitExtension implements IGitExtension { }); } + /** + * Show remote repository for the current repository + * @returns promise which resolves to a list of remote repositories + */ + async getRemotes(): Promise { + const path = await this._getPathRepository(); + const result = await this._taskHandler.execute( + 'git:show:remote', + async () => { + return await requestAPI( + URLExt.join(path, 'remote', 'show'), + 'GET' + ); + } + ); + return result.remotes; + } + + /** + * Remove a remote repository by name + * @param name name of remote to remove + */ + async removeRemote(name: string): Promise { + const path = await this._getPathRepository(); + await this._taskHandler.execute('git:remove:remote', async () => { + await requestAPI(URLExt.join(path, 'remote', name), 'DELETE'); + }); + } + /** * Retrieve the repository commit log. * @@ -942,7 +971,11 @@ export class GitExtension implements IGitExtension { * @throws {Git.GitResponseError} If the server response is not ok * @throws {ServerConnection.NetworkError} If the request cannot be made */ - async push(auth?: Git.IAuth, force = false): Promise { + async push( + auth?: Git.IAuth, + force = false, + remote?: string + ): Promise { const path = await this._getPathRepository(); const data = this._taskHandler.execute( 'git:push', @@ -952,7 +985,8 @@ export class GitExtension implements IGitExtension { 'POST', { auth: auth as any, - force: force + force: force, + remote } ); } diff --git a/src/style/ManageRemoteDialog.ts b/src/style/ManageRemoteDialog.ts new file mode 100644 index 000000000..a4ee2bd53 --- /dev/null +++ b/src/style/ManageRemoteDialog.ts @@ -0,0 +1,38 @@ +import { style } from 'typestyle'; + +export const remoteDialogClass = style({ + color: 'var(--jp-ui-font-color1)!important', + + borderRadius: '3px!important', + + backgroundColor: 'var(--jp-layout-color1)!important' +}); + +export const remoteDialogInputClass = style({ + display: 'flex', + flexDirection: 'column', + $nest: { + '& > input': { + marginTop: '10px', + lineHeight: '20px' + } + } +}); + +export const actionsWrapperClass = style({ + padding: '15px 0px !important', + justifyContent: 'space-around !important' +}); + +export const existingRemoteWrapperClass = style({ + margin: '1.5rem 0rem 1rem', + padding: '0px' +}); + +export const existingRemoteGridClass = style({ + marginTop: '2px', + display: 'grid', + rowGap: '5px', + columnGap: '10px', + gridTemplateColumns: 'auto auto auto' +}); diff --git a/src/tokens.ts b/src/tokens.ts index df1d92545..b57a2f775 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -426,7 +426,11 @@ export interface IGitExtension extends IDisposable { * @throws {Git.GitResponseError} If the server response is not ok * @throws {ServerConnection.NetworkError} If the request cannot be made */ - push(auth?: Git.IAuth, force?: boolean): Promise; + push( + auth?: Git.IAuth, + force?: boolean, + remote?: string + ): Promise; /** * General Git refresh @@ -991,6 +995,24 @@ export namespace Git { cache_credentials?: boolean; } + /** + * Structure for the request to the Git Remote Add API. + */ + export interface IGitRemote { + url: string; + name: string; + } + + /** + * Interface for GitRemoteShowDetails request result, + * has the name and urls of all remotes + */ + export interface IGitRemoteResult { + code: number; + command: string; + remotes: Git.IGitRemote[]; + } + /** * Structure for the request to the Git Clone API. */ @@ -1167,7 +1189,7 @@ export enum CommandIDs { gitOpenUrl = 'git:open-url', gitToggleSimpleStaging = 'git:toggle-simple-staging', gitToggleDoubleClickDiff = 'git:toggle-double-click-diff', - gitAddRemote = 'git:add-remote', + gitManageRemote = 'git:manage-remote', gitClone = 'git:clone', gitMerge = 'git:merge', gitOpenGitignore = 'git:open-gitignore', diff --git a/src/widgets/AdvancedPushForm.tsx b/src/widgets/AdvancedPushForm.tsx new file mode 100644 index 000000000..aa5827f79 --- /dev/null +++ b/src/widgets/AdvancedPushForm.tsx @@ -0,0 +1,123 @@ +import { Dialog } from '@jupyterlab/apputils'; +import { Widget } from '@lumino/widgets'; +import { GitExtension } from '../model'; +import { TranslationBundle } from '@jupyterlab/translation'; + +/** + * Interface for returned value from dialog box + */ +export interface IAdvancedPushFormValue { + /** + * The name of the remote repository to push to. + */ + remoteName: string; + /** + * Whether to use force push. + */ + force: boolean; +} + +/** + * A widget form with advanced push options, + * can be used as a Dialog body. + */ +export class AdvancedPushForm + extends Widget + implements Dialog.IBodyWidget +{ + constructor(trans: TranslationBundle, model: GitExtension) { + super(); + this._trans = trans; + this._model = model; + this._radioButtons = []; + this.node.appendChild(this.createBody()); + this.addRemoteOptions(); + } + + private createBody(): HTMLElement { + const mainNode = document.createElement('div'); + + // Instructional text + const text = document.createElement('div'); + text.className = 'jp-remote-text'; + text.textContent = this._trans.__('Choose a remote to push to.'); + + // List of remotes + const remoteOptionsContainer = document.createElement('div'); + remoteOptionsContainer.className = 'jp-remote-options-wrapper'; + const loadingMessage = document.createElement('div'); + loadingMessage.textContent = this._trans.__( + 'Loading remote repositories...' + ); + remoteOptionsContainer.appendChild(loadingMessage); + this._remoteOptionsContainer = remoteOptionsContainer; + + // Force option + const forceCheckboxContainer = document.createElement('label'); + forceCheckboxContainer.className = 'jp-force-box-container'; + + this._forceCheckbox = document.createElement('input'); + this._forceCheckbox.type = 'checkbox'; + this._forceCheckbox.checked = false; + + const label = document.createElement('span'); + label.textContent = this._trans.__('Force Push'); + + forceCheckboxContainer.appendChild(this._forceCheckbox); + forceCheckboxContainer.appendChild(label); + + mainNode.appendChild(text); + mainNode.appendChild(remoteOptionsContainer); + mainNode.appendChild(forceCheckboxContainer); + + return mainNode; + } + + private async addRemoteOptions(): Promise { + const remotes = await this._model.getRemotes(); + this._remoteOptionsContainer.innerHTML = ''; + if (remotes.length > 0) { + remotes.forEach(remote => { + const buttonWrapper = document.createElement('div'); + buttonWrapper.className = 'jp-button-wrapper'; + const radioButton = document.createElement('input'); + radioButton.type = 'radio'; + radioButton.id = remote.name; + radioButton.value = remote.name; + radioButton.name = 'option'; + radioButton.className = 'jp-option'; + if (remote.name === 'origin') { + radioButton.checked = true; + } + this._radioButtons.push(radioButton); + + const label = document.createElement('label'); + label.htmlFor = remote.name; + label.textContent = `${remote.name}: ${remote.url}`; + + buttonWrapper.appendChild(radioButton); + buttonWrapper.appendChild(label); + this._remoteOptionsContainer.appendChild(buttonWrapper); + }); + } else { + const noRemoteMsg = document.createElement('div'); + noRemoteMsg.textContent = this._trans.__( + 'This repository has no known remotes.' + ); + this._remoteOptionsContainer.appendChild(noRemoteMsg); + } + } + + getValue(): IAdvancedPushFormValue { + return { + remoteName: this._radioButtons.find(rb => rb.checked)?.value, + force: this._forceCheckbox.checked + }; + } + + private _trans: TranslationBundle; + private _model: GitExtension; + private _remoteOptionsContainer: HTMLElement; + private _radioButtons: HTMLInputElement[]; + private _forceCheckbox: HTMLInputElement; +} diff --git a/style/advanced-push-form.css b/style/advanced-push-form.css new file mode 100644 index 000000000..314f3f7d8 --- /dev/null +++ b/style/advanced-push-form.css @@ -0,0 +1,30 @@ +.jp-remote-text { + font-size: 1rem; +} + +.jp-remote-options-wrapper { + margin: 4px; + display: flex; + flex-direction: column; + align-items: stretch; + row-gap: 5px; +} + +.jp-button-wrapper { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.jp-option { + height: fit-content !important; + appearance: auto !important; + margin: 0px; +} + +.jp-force-box-container { + margin-top: 1rem; + display: flex; + align-items: flex-end; + column-gap: 5px; +} \ No newline at end of file diff --git a/style/base.css b/style/base.css index 0a1857c04..a7596bc69 100644 --- a/style/base.css +++ b/style/base.css @@ -9,3 +9,4 @@ @import url('diff-text.css'); @import url('variables.css'); @import url('status-widget.css'); +@import url('advanced-push-form.css'); diff --git a/tests/commands.spec.tsx b/tests/commands.spec.tsx index 983d0b3a1..50df186ba 100644 --- a/tests/commands.spec.tsx +++ b/tests/commands.spec.tsx @@ -58,61 +58,6 @@ describe('git-commands', () => { ); }); - describe('git:add-remote', () => { - it('should admit user and name arguments', async () => { - const name = 'ref'; - const url = 'https://www.mygitserver.com/me/myrepo.git'; - const path = DEFAULT_REPOSITORY_PATH; - - mockGit.requestAPI.mockImplementation( - mockedRequestAPI({ - ...mockResponses, - 'remote/add': { - body: () => { - return { code: 0, command: `git remote add ${name} ${url}` }; - } - } - }) - ); - - model.pathRepository = path; - await model.ready; - - await commands.execute(CommandIDs.gitAddRemote, { url, name }); - - expect(mockGit.requestAPI).toBeCalledWith(`${path}/remote/add`, 'POST', { - url, - name - }); - }); - - it('has optional argument name', async () => { - const name = 'origin'; - const url = 'https://www.mygitserver.com/me/myrepo.git'; - const path = DEFAULT_REPOSITORY_PATH; - - mockGit.requestAPI.mockImplementation( - mockedRequestAPI({ - ...mockResponses, - 'remote/add': { - body: () => { - return { code: 0, command: `git remote add ${name} ${url}` }; - } - } - }) - ); - - model.pathRepository = path; - await model.ready; - - await commands.execute(CommandIDs.gitAddRemote, { url }); - - expect(mockGit.requestAPI).toBeCalledWith(`${path}/remote/add`, 'POST', { - url - }); - }); - }); - describe('git:context-discard', () => { ['staged', 'partially-staged', 'unstaged', 'untracked'].forEach(status => { [' ', 'M', 'A'].forEach(x => { diff --git a/tests/test-components/ManageRemoteDialogue.spec.tsx b/tests/test-components/ManageRemoteDialogue.spec.tsx new file mode 100644 index 000000000..8f15923a4 --- /dev/null +++ b/tests/test-components/ManageRemoteDialogue.spec.tsx @@ -0,0 +1,193 @@ +// @ts-nocheck +import { shallow, mount } from 'enzyme'; +import 'jest'; +import * as React from 'react'; +import { ActionButton } from '../../src/components/ActionButton'; +import { + ManageRemoteDialogue, + IManageRemoteDialogueProps +} from '../../src/components/ManageRemoteDialogue'; +import * as git from '../../src/git'; +import { GitExtension } from '../../src/model'; +import { createButtonClass } from '../../src/style/NewBranchDialog'; +import { + mockedRequestAPI, + defaultMockedResponses, + DEFAULT_REPOSITORY_PATH +} from '../utils'; +import ClearIcon from '@material-ui/icons/Clear'; +import { nullTranslator } from '@jupyterlab/translation'; + +jest.mock('../../src/git'); +jest.mock('@jupyterlab/apputils'); + +const REMOTES = [ + { + name: 'test', + url: 'https://test.com' + }, + { + name: 'origin', + url: 'https://origin.com' + } +]; + +async function createModel() { + const model = new GitExtension(); + model.pathRepository = DEFAULT_REPOSITORY_PATH; + + await model.ready; + return model; +} + +describe('ManageRemoteDialogue', () => { + let model: GitExtension; + const trans = nullTranslator.load('jupyterlab_git'); + + beforeEach(async () => { + jest.restoreAllMocks(); + + const mock = git as jest.Mocked; + mock.requestAPI.mockImplementation( + mockedRequestAPI({ + responses: { + ...defaultMockedResponses, + 'remote/add': { + body: () => { + return { code: 0 }; + } + }, + 'remote/show': { + body: () => { + return { code: 0, remotes: REMOTES }; + } + } + } + }) + ); + + model = await createModel(); + }); + + function createProps( + props?: Partial + ): IManageRemoteDialogueProps { + return { + model: model, + trans: trans, + onClose: () => null, + ...props + }; + } + + describe('constructor', () => { + it('should return a new instance with initial state', () => { + const remoteDialogue = shallow( + + ); + expect(remoteDialogue.instance()).toBeInstanceOf(ManageRemoteDialogue); + const initialState = { + newRemote: { + name: '', + url: '' + }, + existingRemotes: null + }; + expect(remoteDialogue.state()).toEqual(initialState); + }); + + it('should set the correct state after mounting', async () => { + const spyGitGetRemotes = jest.spyOn(GitExtension.prototype, 'getRemotes'); + const spyComponentDidMount = jest.spyOn( + ManageRemoteDialogue.prototype, + 'componentDidMount' + ); + const remoteDialogue = shallow( + + ); + await remoteDialogue.instance().componentDidMount(); + expect(remoteDialogue.state()).toEqual({ + newRemote: { + name: '', + url: '' + }, + existingRemotes: REMOTES + }); + expect(spyGitGetRemotes).toHaveBeenCalledTimes(2); + expect(spyComponentDidMount).toHaveBeenCalledTimes(2); + }); + }); + + describe('render', () => { + it('should display a title for the dialogue "Manage Remotes"', () => { + const remoteDialogue = shallow( + + ); + const node = remoteDialogue.find('p').first(); + expect(node.text()).toEqual('Manage Remotes'); + }); + it('should display a button to close the dialogue', () => { + const remoteDialogue = shallow( + + ); + const nodes = remoteDialogue.find(ClearIcon); + expect(nodes.length).toEqual(1); + }); + + it('should display two input boxes for entering new remote name and url', () => { + const remoteDialogue = shallow( + + ); + const nameInput = remoteDialogue.find('input[placeholder="name"]'); + const urlInput = remoteDialogue.find( + 'input[placeholder="Remote Git repository URL"]' + ); + expect(nameInput.length).toEqual(1); + expect(urlInput.length).toEqual(1); + }); + + it('should display a button to add a new remote', () => { + const remoteDialogue = shallow( + + ); + const node = remoteDialogue.find(`.${createButtonClass}`).first(); + expect(node.prop('value')).toEqual('Add'); + }); + + it('should display buttons to remove existing remotes', async () => { + const remoteDialogue = shallow( + + ); + await remoteDialogue.instance().componentDidMount(); + const nodes = remoteDialogue.find(ActionButton); + expect(nodes.length).toEqual(REMOTES.length); + }); + }); + + describe('functionality', () => { + it('should add a new remote', async () => { + const remoteDialogue = shallow( + + ); + const newRemote = { + name: 'newRemote', + url: 'newremote.com' + }; + await remoteDialogue.setState({ + newRemote + }); + + const spyGitAddRemote = jest.spyOn(GitExtension.prototype, 'addRemote'); + const addRemoteButton = remoteDialogue + .find(`.${createButtonClass}`) + .first(); + addRemoteButton.simulate('click'); + + expect(spyGitAddRemote).toHaveBeenCalledTimes(1); + expect(spyGitAddRemote).toHaveBeenCalledWith( + newRemote.url, + newRemote.name + ); + }); + }); +});