Skip to content

Commit

Permalink
Moved clone command to plugin for extensibility (#1051)
Browse files Browse the repository at this point in the history
* Moved clone command to plugin for extensibility

* Renamed plugin id

Co-authored-by: Frédéric Collonval <[email protected]>

Co-authored-by: Piyush Jain <[email protected]>
Co-authored-by: Frédéric Collonval <[email protected]>
  • Loading branch information
3 people authored Nov 12, 2021
1 parent 7707fc2 commit 7517a87
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 139 deletions.
88 changes: 88 additions & 0 deletions src/cloneCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import { CommandIDs, IGitExtension, Level } from './tokens';
import { IFileBrowserFactory } from '@jupyterlab/filebrowser';
import { Dialog, showDialog } from '@jupyterlab/apputils';
import { GitCloneForm } from './widgets/GitCloneForm';
import { logger } from './logger';
import {
addFileBrowserContextMenu,
IGitCloneArgs,
Operation,
showGitOperationDialog
} from './commandsAndMenu';
import { GitExtension } from './model';
import { addCloneButton } from './widgets/gitClone';

export const gitCloneCommandPlugin: JupyterFrontEndPlugin<void> = {
id: '@jupyterlab/git:clone',
requires: [ITranslator, IGitExtension, IFileBrowserFactory],
activate: (
app: JupyterFrontEnd,
translator: ITranslator,
gitModel: IGitExtension,
fileBrowserFactory: IFileBrowserFactory
) => {
translator = translator || nullTranslator;
const trans = translator.load('jupyterlab_git');
const fileBrowser = fileBrowserFactory.defaultBrowser;
const fileBrowserModel = fileBrowser.model;
/** Add git clone command */
app.commands.addCommand(CommandIDs.gitClone, {
label: trans.__('Clone a Repository'),
caption: trans.__('Clone a repository from a URL'),
isEnabled: () => gitModel.pathRepository === null,
execute: async () => {
const result = await showDialog({
title: trans.__('Clone a repo'),
body: new GitCloneForm(trans),
focusNodeSelector: 'input',
buttons: [
Dialog.cancelButton({ label: trans.__('Cancel') }),
Dialog.okButton({ label: trans.__('Clone') })
]
});

if (result.button.accept && result.value) {
logger.log({
level: Level.RUNNING,
message: trans.__('Cloning…')
});
try {
const details = await showGitOperationDialog<IGitCloneArgs>(
gitModel as GitExtension,
Operation.Clone,
trans,
{ path: fileBrowserModel.path, url: result.value }
);
logger.log({
message: trans.__('Successfully cloned'),
level: Level.SUCCESS,
details
});
await fileBrowserModel.refresh();
} catch (error) {
console.error(
'Encountered an error when cloning the repository. Error: ',
error
);
logger.log({
message: trans.__('Failed to clone'),
level: Level.ERROR,
error: error as Error
});
}
}
}
});
// Add a clone button to the file browser extension toolbar
addCloneButton(gitModel, fileBrowser, app.commands);

// Add the context menu items for the default file browser
addFileBrowserContextMenu(gitModel, fileBrowser, app.contextMenu);
},
autoStart: true
};
202 changes: 74 additions & 128 deletions src/commandsAndMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,8 @@ import {
} from './tokens';
import { GitCredentialsForm } from './widgets/CredentialsBox';
import { discardAllChanges } from './widgets/discardAllChanges';
import { GitCloneForm } from './widgets/GitCloneForm';

interface IGitCloneArgs {
export interface IGitCloneArgs {
/**
* Path in which to clone the Git repository
*/
Expand All @@ -63,7 +62,7 @@ interface IGitCloneArgs {
/**
* Git operations requiring authentication
*/
enum Operation {
export enum Operation {
Clone = 'Clone',
Pull = 'Pull',
Push = 'Push',
Expand Down Expand Up @@ -291,55 +290,6 @@ export function addCommands(
}
});

/** Add git clone command */
commands.addCommand(CommandIDs.gitClone, {
label: trans.__('Clone a Repository'),
caption: trans.__('Clone a repository from a URL'),
isEnabled: () => gitModel.pathRepository === null,
execute: async () => {
const result = await showDialog({
title: trans.__('Clone a repo'),
body: new GitCloneForm(trans),
focusNodeSelector: 'input',
buttons: [
Dialog.cancelButton({ label: trans.__('Cancel') }),
Dialog.okButton({ label: trans.__('Clone') })
]
});

if (result.button.accept && result.value) {
logger.log({
level: Level.RUNNING,
message: trans.__('Cloning…')
});
try {
const details = await Private.showGitOperationDialog<IGitCloneArgs>(
gitModel,
Operation.Clone,
trans,
{ path: fileBrowserModel.path, url: result.value }
);
logger.log({
message: trans.__('Successfully cloned'),
level: Level.SUCCESS,
details
});
await fileBrowserModel.refresh();
} catch (error) {
console.error(
'Encountered an error when cloning the repository. Error: ',
error
);
logger.log({
message: trans.__('Failed to clone'),
level: Level.ERROR,
error: error as Error
});
}
}
}
});

/** Add git open gitignore command */
commands.addCommand(CommandIDs.gitOpenGitignore, {
label: trans.__('Open .gitignore'),
Expand All @@ -364,7 +314,7 @@ export function addCommands(
message: trans.__('Pushing…')
});
try {
const details = await Private.showGitOperationDialog(
const details = await showGitOperationDialog(
gitModel,
args.force ? Operation.ForcePush : Operation.Push,
trans
Expand Down Expand Up @@ -410,7 +360,7 @@ export function addCommands(
level: Level.RUNNING,
message: trans.__('Pulling…')
});
const details = await Private.showGitOperationDialog(
const details = await showGitOperationDialog(
gitModel,
Operation.Pull,
trans
Expand Down Expand Up @@ -1399,84 +1349,80 @@ export function addFileBrowserContextMenu(
}
}

/* eslint-disable no-inner-declarations */
namespace Private {
/**
* Handle Git operation that may require authentication.
*
* @private
* @param model - Git extension model
* @param operation - Git operation name
* @param trans - language translator
* @param args - Git operation arguments
* @param authentication - Git authentication information
* @param retry - Is this operation retried?
* @returns Promise for displaying a dialog
*/
export async function showGitOperationDialog<T>(
model: GitExtension,
operation: Operation,
trans: TranslationBundle,
args?: T,
authentication?: Git.IAuth,
retry = false
): Promise<string> {
try {
let result: Git.IResultWithMessage;
// the Git action
switch (operation) {
case Operation.Clone:
// eslint-disable-next-line no-case-declarations
const { path, url } = args as any as IGitCloneArgs;
result = await model.clone(path, url, authentication);
break;
case Operation.Pull:
result = await model.pull(authentication);
break;
case Operation.Push:
result = await model.push(authentication);
break;
case Operation.ForcePush:
result = await model.push(authentication, true);
break;
default:
result = { code: -1, message: 'Unknown git command' };
break;
}
/**
* Handle Git operation that may require authentication.
*
* @private
* @param model - Git extension model
* @param operation - Git operation name
* @param trans - language translator
* @param args - Git operation arguments
* @param authentication - Git authentication information
* @param retry - Is this operation retried?
* @returns Promise for displaying a dialog
*/
export async function showGitOperationDialog<T>(
model: GitExtension,
operation: Operation,
trans: TranslationBundle,
args?: T,
authentication?: Git.IAuth,
retry = false
): Promise<string> {
try {
let result: Git.IResultWithMessage;
// the Git action
switch (operation) {
case Operation.Clone:
// eslint-disable-next-line no-case-declarations
const { path, url } = args as any as IGitCloneArgs;
result = await model.clone(path, url, authentication);
break;
case Operation.Pull:
result = await model.pull(authentication);
break;
case Operation.Push:
result = await model.push(authentication);
break;
case Operation.ForcePush:
result = await model.push(authentication, true);
break;
default:
result = { code: -1, message: 'Unknown git command' };
break;
}

return result.message;
} catch (error) {
if (
AUTH_ERROR_MESSAGES.some(
errorMessage => (error as Error).message.indexOf(errorMessage) > -1
return result.message;
} catch (error) {
if (
AUTH_ERROR_MESSAGES.some(
errorMessage => (error as Error).message.indexOf(errorMessage) > -1
)
) {
// If the error is an authentication error, ask the user credentials
const credentials = await showDialog({
title: trans.__('Git credentials required'),
body: new GitCredentialsForm(
trans,
trans.__('Enter credentials for remote repository'),
retry ? trans.__('Incorrect username or password.') : ''
)
) {
// If the error is an authentication error, ask the user credentials
const credentials = await showDialog({
title: trans.__('Git credentials required'),
body: new GitCredentialsForm(
trans,
trans.__('Enter credentials for remote repository'),
retry ? trans.__('Incorrect username or password.') : ''
)
});
});

if (credentials.button.accept) {
// Retry the operation if the user provides its credentials
return await showGitOperationDialog<T>(
model,
operation,
trans,
args,
credentials.value,
true
);
}
if (credentials.button.accept) {
// Retry the operation if the user provides its credentials
return await showGitOperationDialog<T>(
model,
operation,
trans,
args,
credentials.value,
true
);
}
// Throw the error if it cannot be handled or
// if the user did not accept to provide its credentials
throw error;
}
// Throw the error if it cannot be handled or
// if the user did not accept to provide its credentials
throw error;
}
}
/* eslint-enable no-inner-declarations */
20 changes: 11 additions & 9 deletions src/components/GitPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,15 +472,17 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
>
{this.props.trans.__('Initialize a Repository')}
</button>
<button
className={repoButtonClass}
onClick={async () => {
await commands.execute(CommandIDs.gitClone);
await commands.execute('filebrowser:toggle-main');
}}
>
{this.props.trans.__('Clone a Repository')}
</button>
{commands.hasCommand(CommandIDs.gitClone) && (
<button
className={repoButtonClass}
onClick={async () => {
await commands.execute(CommandIDs.gitClone);
await commands.execute('filebrowser:toggle-main');
}}
>
{this.props.trans.__('Clone a Repository')}
</button>
)}
</React.Fragment>
);
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { gitIcon } from './style/icons';
import { Git, IGitExtension } from './tokens';
import { addCloneButton } from './widgets/gitClone';
import { GitWidget } from './widgets/GitWidget';
import { gitCloneCommandPlugin } from './cloneCommand';

export { DiffModel } from './components/diff/model';
export { NotebookDiff } from './components/diff/NotebookDiff';
Expand Down Expand Up @@ -54,7 +55,7 @@ const plugin: JupyterFrontEndPlugin<IGitExtension> = {
/**
* Export the plugin as default.
*/
export default plugin;
export default [plugin, gitCloneCommandPlugin];

/**
* Activate the running plugin.
Expand Down
4 changes: 3 additions & 1 deletion tests/plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'jest';
import * as git from '../src/git';
import plugin from '../src/index';
import plugins from '../src/index';
import { version } from '../src/version';
import { ISettingRegistry, SettingRegistry } from '@jupyterlab/settingregistry';
import { JupyterLab } from '@jupyterlab/application';
Expand All @@ -17,6 +17,8 @@ jest.mock('@jupyterlab/application');
jest.mock('@jupyterlab/apputils');
jest.mock('@jupyterlab/settingregistry');

const plugin = plugins[0];

describe('plugin', () => {
const mockGit = git as jest.Mocked<typeof git>;
let app: jest.Mocked<JupyterLab>;
Expand Down

0 comments on commit 7517a87

Please sign in to comment.