diff --git a/README.md b/README.md index 7e178eab0..8cb323747 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,11 @@ jupyter lab build Once installed, extension behavior can be modified via the following settings which can be set in JupyterLab's advanced settings editor: +- **blockWhileCommandExecutes**: suspend JupyterLab user interaction until Git commands (e.g., `commit`, `pull`, `reset`, `revert`) finish executing. Setting this to `true` helps mitigate potential race conditions leading to data loss, conflicts, and a broken Git history. Unless running a slow network, UI suspension should not interfere with standard workflows. Setting this to `false` allows for actions to trigger multiple concurrent Git actions. +- **cancelPullMergeConflict**: cancel pulling changes from a remote repository if there exists a merge conflict. If set to `true`, when fetching and integrating changes from a remote repository, a conflicting merge is canceled and the working tree left untouched. - **disableBranchWithChanges**: disable all branch operations, such as creating a new branch or switching to a different branch, when there are changed/staged files. When set to `true`, this setting guards against overwriting and/or losing uncommitted changes. -- **doubleClickDiff**: double click a file in the Git UI to open a diff of the file instead of opening the file for editing. +- **displayStatus**: display Git extension status updates in the JupyterLab status bar. If `true`, the extension displays status updates in the JupyterLab status bar, such as when pulling and pushing changes, switching branches, and polling for changes. Depending on the level of extension activity, some users may find the status updates distracting. In which case, setting this to `false` should reduce visual noise. +- **doubleClickDiff**: double click a file in the Git extension panel to open a diff of the file instead of opening the file for editing. - **historyCount**: number of commits shown in the history log, beginning with the most recent. Displaying a larger number of commits can lead to performance degradation, so use caution when modifying this setting. - **refreshInterval**: number of milliseconds between polling the file system for changes. In order to ensure that the UI correctly displays the current repository status, the extension must poll the file system for changes. Longer polling times increase the likelihood that the UI does not reflect the current status; however, longer polling times also incur less performance overhead. - **simpleStaging**: enable a simplified concept of staging. When this setting is `true`, all files with changes are automatically staged. When we develop in JupyterLab, we often only care about what files have changed (in the broadest sense) and don't need to distinguish between "tracked" and "untracked" files. Accordingly, this setting allows us to simplify the visual presentation of changes, which is especially useful for those less acquainted with Git. diff --git a/package.json b/package.json index 04361eb61..05d8ee256 100644 --- a/package.json +++ b/package.json @@ -62,10 +62,12 @@ "@jupyterlab/settingregistry": "^2.0.0", "@jupyterlab/terminal": "^2.0.0", "@jupyterlab/ui-components": "^2.0.0", + "@lumino/collections": "^1.2.3", "@lumino/polling": "^1.0.4", "@lumino/widgets": "^1.11.1", "@material-ui/core": "^4.8.2", "@material-ui/icons": "^4.5.1", + "@material-ui/lab": "^4.0.0-alpha.54", "diff-match-patch": "^1.0.4", "nbdime": "^6.0.0", "react": "~16.9.0", diff --git a/schema/plugin.json b/schema/plugin.json index f30944f28..32142b64b 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -5,6 +5,12 @@ "description": "jupyterlab-git settings.", "type": "object", "properties": { + "blockWhileCommandExecutes": { + "type": "boolean", + "title": "Suspend user interaction until commands finish", + "description": "Suspend JupyterLab user interaction until Git commands (e.g., commit, pull, reset, revert) finish executing. Setting this to true helps mitigate potential race conditions leading to data loss, conflicts, and a broken Git history. Unless running a slow network, UI suspension should not interfere with standard workflows. Setting this to false allows for actions to trigger multiple concurrent Git actions.", + "default": true + }, "cancelPullMergeConflict": { "type": "boolean", "title": "Cancel pull merge conflict", @@ -17,10 +23,16 @@ "description": "Disable all branch operations (new, switch) when there are changed/staged files", "default": false }, + "displayStatus": { + "type": "boolean", + "title": "Display Git status updates", + "description": "Display Git extension status updates in the JupyterLab status bar. If true, the extension displays status updates in the JupyterLab status bar, such as when pulling and pushing changes, switching branches, and polling for changes. Depending on the level of extension activity, some users may find the status updates distracting. In which case, setting this to false should reduce visual noise.", + "default": true + }, "doubleClickDiff": { "type": "boolean", "title": "Show diff on double click", - "description": "If true, doubling clicking a file in the list of changed files will open a diff", + "description": "If true, doubling clicking a file in the list of changed files will open a diff.", "default": false }, "historyCount": { diff --git a/src/gitMenuCommands.ts b/src/commandsAndMenu.ts similarity index 84% rename from src/gitMenuCommands.ts rename to src/commandsAndMenu.ts index ed203a479..15cd8c52b 100644 --- a/src/gitMenuCommands.ts +++ b/src/commandsAndMenu.ts @@ -9,10 +9,23 @@ import { import { FileBrowser } from '@jupyterlab/filebrowser'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminal } from '@jupyterlab/terminal'; +import { CommandRegistry } from '@lumino/commands'; +import { Menu } from '@lumino/widgets'; import { IGitExtension } from './tokens'; +import { GitCredentialsForm } from './widgets/CredentialsBox'; import { doGitClone } from './widgets/gitClone'; import { GitPullPushDialog, Operation } from './widgets/gitPushPull'; -import { GitCredentialsForm } from './widgets/CredentialsBox'; + +const RESOURCES = [ + { + text: 'Set Up Remotes', + url: 'https://www.atlassian.com/git/tutorials/setting-up-a-repository' + }, + { + text: 'Git Documentation', + url: 'https://git-scm.com/doc' + } +]; /** * The command IDs used by the git plugin. @@ -210,6 +223,52 @@ export function addCommands( }); } +/** + * Adds commands and menu items. + * + * @private + * @param app - Jupyter front end + * @param gitExtension - Git extension instance + * @param fileBrowser - file browser instance + * @param settings - extension settings + * @returns menu + */ +export function createGitMenu(commands: CommandRegistry): Menu { + const menu = new Menu({ commands }); + menu.title.label = 'Git'; + [ + CommandIDs.gitInit, + CommandIDs.gitClone, + CommandIDs.gitPush, + CommandIDs.gitPull, + CommandIDs.gitAddRemote, + CommandIDs.gitTerminalCommand + ].forEach(command => { + menu.addItem({ command }); + }); + + menu.addItem({ type: 'separator' }); + + menu.addItem({ command: CommandIDs.gitToggleSimpleStaging }); + + menu.addItem({ command: CommandIDs.gitToggleDoubleClickDiff }); + + menu.addItem({ type: 'separator' }); + + const tutorial = new Menu({ commands }); + tutorial.title.label = ' Help '; + RESOURCES.map(args => { + tutorial.addItem({ + args, + command: CommandIDs.gitOpenUrl + }); + }); + + menu.addItem({ type: 'submenu', submenu: tutorial }); + + return menu; +} + /* eslint-disable no-inner-declarations */ namespace Private { /** diff --git a/src/components/Alert.tsx b/src/components/Alert.tsx new file mode 100644 index 000000000..b41a07e1d --- /dev/null +++ b/src/components/Alert.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import Portal from '@material-ui/core/Portal'; +import Snackbar from '@material-ui/core/Snackbar'; +import Slide from '@material-ui/core/Slide'; +import { default as MuiAlert } from '@material-ui/lab/Alert'; +import { Severity } from '../tokens'; + +/** + * Returns a React component for "sliding-in" an alert. + * + * @private + * @param props - component properties + * @returns React element + */ +function SlideTransition(props: any): React.ReactElement { + return ; +} + +/** + * Interface describing component properties. + */ +export interface IAlertProps { + /** + * Boolean indicating whether to display an alert. + */ + open: boolean; + + /** + * Alert message. + */ + message: string; + + /** + * Alert severity. + */ + severity?: Severity; + + /** + * Alert duration (in milliseconds). + */ + duration?: number; + + /** + * Callback invoked upon clicking on an alert. + */ + onClick?: (event?: any) => void; + + /** + * Callback invoked upon closing an alert. + */ + onClose: (event?: any) => void; +} + +/** + * React component for rendering an alert. + */ +export class Alert extends React.Component { + /** + * Returns a React component for rendering an alert. + * + * @param props - component properties + * @returns React component + */ + constructor(props: IAlertProps) { + super(props); + } + + /** + * Renders the component. + * + * @returns React element + */ + render(): React.ReactElement { + let duration: number | null = null; + + const severity = this.props.severity || 'info'; + if (severity === 'success') { + duration = this.props.duration || 5000; // milliseconds + } + return ( + + + + {this.props.message || '(missing message)'} + + + + ); + } + + /** + * Callback invoked upon clicking on an alert. + * + * @param event - event object + */ + private _onClick = (event: any): void => { + if (this.props.onClick) { + this.props.onClick(event); + return; + } + this._onClose(event, 'click'); + }; + + /** + * Callback invoked upon closing an alert. + * + * @param event - event object + * @param reason - reason why the callback was invoked + */ + private _onClose = (event: any, reason: string): void => { + if (reason === 'clickaway') { + return; + } + this.props.onClose(event); + }; +} diff --git a/src/components/BranchMenu.tsx b/src/components/BranchMenu.tsx index 0940b3b0f..7262d66fe 100644 --- a/src/components/BranchMenu.tsx +++ b/src/components/BranchMenu.tsx @@ -16,12 +16,61 @@ import { newBranchButtonClass, wrapperClass } from '../style/BranchMenu'; -import { Git, IGitExtension } from '../tokens'; +import { Git, IGitExtension, ILogMessage } from '../tokens'; +import { sleep } from '../utils'; +import { Alert } from './Alert'; import { NewBranchDialog } from './NewBranchDialog'; +import { SuspendModal } from './SuspendModal'; const CHANGES_ERR_MSG = 'The current branch contains files with uncommitted changes. Please commit or discard these changes before switching to or creating another branch.'; +/** + * Callback invoked upon encountering an error when switching branches. + * + * @private + * @param err - error + */ +function onBranchError(err: any): void { + if (err.message.includes('following files would be overwritten')) { + showDialog({ + title: 'Unable to switch branch', + body: ( + +

+ Your changes to the following files would be overwritten by + switching: +

+ + {err.message + .split('\n') + .slice(1, -3) + .map(renderFileName)} + + + Please commit, stash, or discard your changes before you switch + branches. + +
+ ), + buttons: [Dialog.okButton({ label: 'Dismiss' })] + }); + } else { + showErrorMessage('Error switching branch', err.message); + } +} + +/** + * Renders a file name. + * + * @private + * @param filename - file name + * @returns React element + */ +function renderFileName(filename: string): React.ReactElement { + return {filename}; +} + /** * Interface describing component properties. */ @@ -35,6 +84,11 @@ export interface IBranchMenuProps { * Boolean indicating whether branching is disabled. */ branching: boolean; + + /** + * Boolean indicating whether to enable UI suspension. + */ + suspend: boolean; } /** @@ -60,6 +114,21 @@ export interface IBranchMenuState { * Current list of branches. */ branches: Git.IBranch[]; + + /** + * Boolean indicating whether UI interaction should be suspended (e.g., due to pending command). + */ + suspend: boolean; + + /** + * Boolean indicating whether to show an alert. + */ + alert: boolean; + + /** + * Log message. + */ + log: ILogMessage; } /** @@ -84,7 +153,13 @@ export class BranchMenu extends React.Component< filter: '', branchDialog: false, current: repo ? this.props.model.currentBranch.name : '', - branches: repo ? this.props.model.branches : [] + branches: repo ? this.props.model.branches : [], + suspend: false, + alert: false, + log: { + severity: 'info', + message: '' + } }; } @@ -110,46 +185,65 @@ export class BranchMenu extends React.Component< render(): React.ReactElement { return (
-
-
- - {this.state.filter ? ( - - ) : null} -
+ {this._renderFilter()} + {this._renderBranchList()} + {this._renderNewBranchDialog()} + {this._renderFeedback()} +
+ ); + } + + /** + * Renders a branch input filter. + * + * @returns React element + */ + private _renderFilter(): React.ReactElement { + return ( +
+
+ {this.state.filter ? ( + + ) : null}
-
- {this._renderItems()} -
-
); } + /** + * Renders a + * + * @returns React element + */ + private _renderBranchList(): React.ReactElement { + return ( +
+ {this._renderItems()} +
+ ); + } + /** * Renders menu items. * @@ -198,6 +292,44 @@ export class BranchMenu extends React.Component< ); } + /** + * Renders a dialog for creating a new branch. + * + * @returns React element + */ + private _renderNewBranchDialog(): React.ReactElement { + return ( + + ); + } + + /** + * Renders a component to provide UI feedback. + * + * @returns React element + */ + private _renderFeedback(): React.ReactElement { + return ( + + + + + ); + } + /** * Adds model listeners. */ @@ -228,6 +360,31 @@ export class BranchMenu extends React.Component< }); } + /** + * Sets the suspension state. + * + * @param bool - boolean indicating whether to suspend UI interaction + */ + private _suspend(bool: boolean): void { + if (this.props.suspend) { + this.setState({ + suspend: bool + }); + } + } + + /** + * Sets the current component log message. + * + * @param msg - log message + */ + private _log(msg: ILogMessage): void { + this.setState({ + alert: true, + log: msg + }); + } + /** * Callback invoked upon a change to the menu filter. * @@ -287,8 +444,9 @@ export class BranchMenu extends React.Component< * * @private * @param event - event object + * @returns promise which resolves upon attempting to switch branches */ - function onClick(): void { + async function onClick(): Promise { if (!self.props.branching) { showErrorMessage('Switching branches is disabled', CHANGES_ERR_MSG); return; @@ -296,66 +454,61 @@ export class BranchMenu extends React.Component< const opts = { branchname: branch }; - self.props.model - .checkout(opts) - .then(onResolve) - .catch(onError); - } - /** - * Callback invoked upon promise resolution. - * - * @private - * @param result - result - */ - function onResolve(result: any): void { - if (result.code !== 0) { - showErrorMessage('Error switching branch', result.message); - } - } + self._log({ + severity: 'info', + message: 'Switching branch...' + }); + self._suspend(true); - /** - * Callback invoked upon encountering an error. - * - * @private - * @param err - error - */ - function onError(err: any): void { - if (err.message.includes('following files would be overwritten')) { - showDialog({ - title: 'Unable to switch branch', - body: ( - -

- Your changes to the following files would be overwritten by - switching: -

- - {err.message - .split('\n') - .slice(1, -3) - .map(renderFileName)} - - - Please commit, stash, or discard your changes before you switch - branches. - -
- ), - buttons: [Dialog.okButton({ label: 'Dismiss' })] + let result: Array; + try { + result = await Promise.all([ + sleep(1000), + self.props.model.checkout(opts) + ]); + } catch (err) { + self._suspend(false); + self._log({ + severity: 'error', + message: 'Failed to switch branch.' }); - } else { - showErrorMessage('Error switching branch', err.message); + return onBranchError(err); } - } - - /** - * Render a filename into a list - * @param filename - * @returns ReactElement - */ - function renderFileName(filename: string): React.ReactElement { - return {filename}; + self._suspend(false); + const res = result[1] as Git.ICheckoutResult; + if (res.code !== 0) { + self._log({ + severity: 'error', + message: 'Failed to switch branch.' + }); + showErrorMessage('Error switching branch', res.message); + return; + } + self._log({ + severity: 'success', + message: 'Switched branch.' + }); } } + + /** + * Callback invoked upon clicking on the feedback modal. + * + * @param event - event object + */ + private _onFeedbackModalClick = (): void => { + this._suspend(false); + }; + + /** + * Callback invoked upon closing a feedback alert. + * + * @param event - event object + */ + private _onFeedbackAlertClose = (): void => { + this.setState({ + alert: false + }); + }; } diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 2b1dd9544..21fdfb8f5 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -1,13 +1,16 @@ +import * as React from 'react'; +import Tab from '@material-ui/core/Tab'; +import Tabs from '@material-ui/core/Tabs'; import { showDialog, showErrorMessage } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; import { FileBrowserModel } from '@jupyterlab/filebrowser'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { JSONObject } from '@lumino/coreutils'; -import Tab from '@material-ui/core/Tab'; -import Tabs from '@material-ui/core/Tabs'; -import * as React from 'react'; import { GitExtension } from '../model'; +import { sleep } from '../utils'; +import { Git, ILogMessage } from '../tokens'; +import { GitAuthorForm } from '../widgets/AuthorBox'; import { panelWrapperClass, repoButtonClass, @@ -17,38 +20,100 @@ import { tabsClass, warningTextClass } from '../style/GitPanel'; -import { Git } from '../tokens'; -import { GitAuthorForm } from '../widgets/AuthorBox'; import { CommitBox } from './CommitBox'; import { FileList } from './FileList'; import { HistorySideBar } from './HistorySideBar'; import { Toolbar } from './Toolbar'; -import { CommandIDs } from '../gitMenuCommands'; +import { SuspendModal } from './SuspendModal'; +import { Alert } from './Alert'; +import { CommandIDs } from '../commandsAndMenu'; + +/** + * Interface describing component properties. + */ +export interface IGitPanelProps { + /** + * Git extension data model. + */ + model: GitExtension; + + /** + * MIME type registry. + */ + renderMime: IRenderMimeRegistry; -/** Interface for GitPanel component state */ -export interface IGitSessionNodeState { + /** + * Git extension settings. + */ + settings: ISettingRegistry.ISettings; + + /** + * File browser model. + */ + filebrowser: FileBrowserModel; +} + +/** + * Interface describing component state. + */ +export interface IGitPanelState { + /** + * Boolean indicating whether the user is currently in a Git repository. + */ + inGitRepository: boolean; + + /** + * List of branches. + */ branches: Git.IBranch[]; + + /** + * Current branch. + */ currentBranch: string; + + /** + * List of changed files. + */ files: Git.IStatusFile[]; - inGitRepository: boolean; + + /** + * List of prior commits. + */ pastCommits: Git.ISingleCommitInfo[]; + + /** + * Panel tab identifier. + */ tab: number; -} -/** Interface for GitPanel component props */ -export interface IGitSessionNodeProps { - model: GitExtension; - renderMime: IRenderMimeRegistry; - settings: ISettingRegistry.ISettings; - filebrowser: FileBrowserModel; + /** + * Boolean indicating whether UI interaction should be suspended (e.g., due to pending command). + */ + suspend: boolean; + + /** + * Boolean indicating whether to show an alert. + */ + alert: boolean; + + /** + * Log message. + */ + log: ILogMessage; } -/** A React component for the git extension's main display */ -export class GitPanel extends React.Component< - IGitSessionNodeProps, - IGitSessionNodeState -> { - constructor(props: IGitSessionNodeProps) { +/** + * React component for rendering a panel for performing Git operations. + */ +export class GitPanel extends React.Component { + /** + * Returns a React component for rendering a panel for performing Git operations. + * + * @param props - component properties + * @returns React component + */ + constructor(props: IGitPanelProps) { super(props); this.state = { branches: [], @@ -56,10 +121,19 @@ export class GitPanel extends React.Component< files: [], inGitRepository: false, pastCommits: [], - tab: 0 + tab: 0, + suspend: false, + alert: false, + log: { + severity: 'info', + message: '' + } }; } + /** + * Callback invoked immediately after mounting a component (i.e., inserting into a tree). + */ componentDidMount() { const { model, settings } = this.props; @@ -133,9 +207,17 @@ export class GitPanel extends React.Component< * @returns a promise which commits the files */ commitMarkedFiles = async (message: string): Promise => { + this._suspend(true); + + this._log({ + severity: 'info', + message: 'Staging files...' + }); await this.props.model.reset(); await this.props.model.add(...this._markedFiles.map(file => file.to)); + await this.commitStagedFiles(message); + this._suspend(false); }; /** @@ -145,18 +227,50 @@ export class GitPanel extends React.Component< * @returns a promise which commits the files */ commitStagedFiles = async (message: string): Promise => { + let res: boolean; + if (!message) { + return; + } try { - if ( - message && - message !== '' && - (await this._hasIdentity(this.props.model.pathRepository)) - ) { - await this.props.model.commit(message); - } - } catch (error) { - console.error(error); - showErrorMessage('Fail to commit', error); + res = await this._hasIdentity(this.props.model.pathRepository); + } catch (err) { + this._log({ + severity: 'error', + message: 'Failed to commit changes.' + }); + console.error(err); + showErrorMessage('Fail to commit', err); + return; } + if (!res) { + this._log({ + severity: 'error', + message: 'Failed to commit changes.' + }); + return; + } + this._log({ + severity: 'info', + message: 'Committing changes...' + }); + this._suspend(true); + try { + await Promise.all([sleep(1000), this.props.model.commit(message)]); + } catch (err) { + this._suspend(false); + this._log({ + severity: 'error', + message: 'Failed to commit changes.' + }); + console.error(err); + showErrorMessage('Fail to commit', err); + return; + } + this._suspend(false); + this._log({ + severity: 'success', + message: 'Committed changes.' + }); }; /** @@ -171,6 +285,7 @@ export class GitPanel extends React.Component< {this._renderToolbar()} {this._renderMain()} + {this._renderFeedback()} ) : ( this._renderWarning() @@ -194,6 +309,9 @@ export class GitPanel extends React.Component< model={this.props.model} branching={!disableBranching} refresh={this._onRefresh} + suspend={ + this.props.settings.composite['blockWhileCommandExecutes'] as boolean + } /> ); } @@ -292,10 +410,38 @@ export class GitPanel extends React.Component< commits={this.state.pastCommits} model={this.props.model} renderMime={this.props.renderMime} + suspend={ + this.props.settings.composite['blockWhileCommandExecutes'] as boolean + } /> ); } + /** + * Renders a component to provide UI feedback. + * + * @returns React element + */ + private _renderFeedback(): React.ReactElement { + return ( + + + + + ); + } + /** * Renders a panel for prompting a user to find a Git repository. * @@ -344,6 +490,31 @@ export class GitPanel extends React.Component< ); } + /** + * Sets the suspension state. + * + * @param bool - boolean indicating whether to suspend UI interaction + */ + private _suspend(bool: boolean): void { + if (this.props.settings.composite['blockWhileCommandExecutes']) { + this.setState({ + suspend: bool + }); + } + } + + /** + * Sets the current component log message. + * + * @param msg - log message + */ + private _log(msg: ILogMessage): void { + this.setState({ + alert: true, + log: msg + }); + } + /** * Callback invoked upon changing the active panel tab. * @@ -373,6 +544,26 @@ export class GitPanel extends React.Component< } }; + /** + * Callback invoked upon clicking on the feedback modal. + * + * @param event - event object + */ + private _onFeedbackModalClick = (): void => { + this._suspend(false); + }; + + /** + * Callback invoked upon closing a feedback alert. + * + * @param event - event object + */ + private _onFeedbackAlertClose = (): void => { + this.setState({ + alert: false + }); + }; + /** * Determines whether a user has a known Git identity. * diff --git a/src/components/HistorySideBar.tsx b/src/components/HistorySideBar.tsx index e845d90a4..ecef1ce68 100644 --- a/src/components/HistorySideBar.tsx +++ b/src/components/HistorySideBar.tsx @@ -28,6 +28,11 @@ export interface IHistorySideBarProps { * Render MIME type registry. */ renderMime: IRenderMimeRegistry; + + /** + * Boolean indicating whether to enable UI suspension. + */ + suspend: boolean; } /** @@ -47,6 +52,7 @@ export const HistorySideBar: React.FunctionComponent = ( branches={props.branches} model={props.model} renderMime={props.renderMime} + suspend={props.suspend} /> ))} diff --git a/src/components/NewBranchDialog.tsx b/src/components/NewBranchDialog.tsx index c176cb0a3..42f29523b 100644 --- a/src/components/NewBranchDialog.tsx +++ b/src/components/NewBranchDialog.tsx @@ -5,8 +5,8 @@ import ListItem from '@material-ui/core/ListItem'; import Dialog from '@material-ui/core/Dialog'; import DialogActions from '@material-ui/core/DialogActions'; import ClearIcon from '@material-ui/icons/Clear'; -import { showErrorMessage } from '@jupyterlab/apputils'; -import { Git, IGitExtension } from '../tokens'; +import { sleep } from '../utils'; +import { Git, IGitExtension, ILogMessage } from '../tokens'; import { actionsWrapperClass, activeListItemClass, @@ -16,6 +16,7 @@ import { closeButtonClass, contentWrapperClass, createButtonClass, + errorMessageClass, filterClass, filterClearClass, filterInputClass, @@ -31,6 +32,8 @@ import { titleClass, titleWrapperClass } from '../style/NewBranchDialog'; +import { SuspendModal } from './SuspendModal'; +import { Alert } from './Alert'; const BRANCH_DESC = { current: @@ -53,6 +56,11 @@ export interface INewBranchDialogProps { */ open: boolean; + /** + * Boolean indicating whether to enable UI suspension. + */ + suspend: boolean; + /** * Callback to invoke upon closing the dialog. */ @@ -87,6 +95,26 @@ export interface INewBranchDialogState { * Current list of branches. */ branches: Git.IBranch[]; + + /** + * Error message. + */ + error: string; + + /** + * Boolean indicating whether UI interaction should be suspended (e.g., due to pending command). + */ + suspend: boolean; + + /** + * Boolean indicating whether to show an alert. + */ + alert: boolean; + + /** + * Log message. + */ + log: ILogMessage; } /** @@ -112,7 +140,14 @@ export class NewBranchDialog extends React.Component< base: repo ? this.props.model.currentBranch.name : '', filter: '', current: repo ? this.props.model.currentBranch.name : '', - branches: repo ? this.props.model.branches : [] + branches: repo ? this.props.model.branches : [], + error: '', + suspend: false, + alert: false, + log: { + severity: 'info', + message: '' + } }; } @@ -136,6 +171,20 @@ export class NewBranchDialog extends React.Component< * @returns React element */ render(): React.ReactElement { + return ( + + {this._renderDialog()} + {this._renderFeedback()} + + ); + } + + /** + * Renders a dialog for creating a new branch. + * + * @returns React element + */ + private _renderDialog(): React.ReactElement { return (
+ {this.state.error ? ( +

{this.state.error}

+ ) : null}

Name

@@ -301,6 +353,28 @@ export class NewBranchDialog extends React.Component< ); } + /** + * Renders a component to provide UI feedback. + * + * @returns React element + */ + private _renderFeedback(): React.ReactElement { + return ( + + + + + ); + } + /** * Adds model listeners. */ @@ -332,6 +406,31 @@ export class NewBranchDialog extends React.Component< }); } + /** + * Sets the suspension state. + * + * @param bool - boolean indicating whether to suspend UI interaction + */ + private _suspend(bool: boolean): void { + if (this.props.suspend) { + this.setState({ + suspend: bool + }); + } + } + + /** + * Sets the current component log message. + * + * @param msg - log message + */ + private _log(msg: ILogMessage): void { + this.setState({ + alert: true, + log: msg + }); + } + /** * Callback invoked upon closing the dialog. * @@ -341,7 +440,8 @@ export class NewBranchDialog extends React.Component< this.props.onClose(); this.setState({ name: '', - filter: '' + filter: '', + error: '' }); }; @@ -395,7 +495,8 @@ export class NewBranchDialog extends React.Component< */ private _onNameChange = (event: any): void => { this.setState({ - name: event.target.value + name: event.target.value, + error: '' }); }; @@ -413,50 +514,79 @@ export class NewBranchDialog extends React.Component< * Creates a new branch. * * @param branch - branch name + * @returns promise which resolves upon attempting to create a new branch */ - private _createBranch(branch: string): void { - const self = this; + private async _createBranch(branch: string): Promise { + let result: Array; + const opts = { newBranch: true, branchname: branch }; - this.props.model - .checkout(opts) - .then(onResolve) - .catch(onError); - - /** - * Callback invoked upon promise resolution. - * - * @private - * @param result - result - */ - function onResolve(result: any): void { - if (result.code !== 0) { - showErrorMessage('Error creating branch', result.message); - } else { - // Close the branch dialog: - self.props.onClose(); - - // Reset the branch name and filter: - self.setState({ - name: '', - filter: '' - }); - } + this._log({ + severity: 'info', + message: 'Creating branch...' + }); + this._suspend(true); + try { + result = await Promise.all([ + sleep(1000), + this.props.model.checkout(opts) + ]); + } catch (err) { + this._suspend(false); + this.setState({ + error: err.message.replace(/^fatal:/, '') + }); + this._log({ + severity: 'error', + message: 'Failed to create branch.' + }); + return; } - - /** - * Callback invoked upon encountering an error. - * - * @private - * @param err - error - */ - function onError(err: any): void { - showErrorMessage( - 'Error creating branch', - err.message.replace(/^fatal:/, '') - ); + this._suspend(false); + const res = result[1] as Git.ICheckoutResult; + if (res.code !== 0) { + this.setState({ + error: res.message + }); + this._log({ + severity: 'error', + message: 'Failed to create branch.' + }); + return; } + this._log({ + severity: 'success', + message: 'Branch created.' + }); + // Close the branch dialog: + this.props.onClose(); + + // Reset the branch name and filter: + this.setState({ + name: '', + filter: '' + }); } + + /** + * Callback invoked upon clicking on the feedback modal. + * + * @param event - event object + */ + private _onFeedbackModalClick = (): void => { + this._suspend(false); + }; + + /** + * Callback invoked upon closing a feedback alert. + * + * @param event - event object + */ + private _onFeedbackAlertClose = (): void => { + this.setState({ + alert: false + }); + }; } diff --git a/src/components/PastCommitNode.tsx b/src/components/PastCommitNode.tsx index 35d491c5f..ec745cfd5 100644 --- a/src/components/PastCommitNode.tsx +++ b/src/components/PastCommitNode.tsx @@ -42,6 +42,11 @@ export interface IPastCommitNodeProps { * Render MIME type registry. */ renderMime: IRenderMimeRegistry; + + /** + * Boolean indicating whether to enable UI suspension. + */ + suspend: boolean; } /** @@ -112,6 +117,7 @@ export class PastCommitNode extends React.Component< commit={this.props.commit} model={this.props.model} renderMime={this.props.renderMime} + suspend={this.props.suspend} /> )}
diff --git a/src/components/ResetRevertDialog.tsx b/src/components/ResetRevertDialog.tsx index 7db3800ad..c3849d0bf 100644 --- a/src/components/ResetRevertDialog.tsx +++ b/src/components/ResetRevertDialog.tsx @@ -5,7 +5,8 @@ import { showErrorMessage } from '@jupyterlab/apputils'; import Dialog from '@material-ui/core/Dialog'; import DialogActions from '@material-ui/core/DialogActions'; import ClearIcon from '@material-ui/icons/Clear'; -import { Git, IGitExtension } from '../tokens'; +import { sleep } from '../utils'; +import { Git, IGitExtension, ILogMessage } from '../tokens'; import { actionsWrapperClass, commitFormClass, @@ -20,6 +21,8 @@ import { titleClass, titleWrapperClass } from '../style/ResetRevertDialog'; +import { SuspendModal } from './SuspendModal'; +import { Alert } from './Alert'; /** * Interface describing component properties. @@ -45,6 +48,11 @@ export interface IResetRevertDialogProps { */ open: boolean; + /** + * Boolean indicating whether to enable UI suspension. + */ + suspend: boolean; + /** * Callback invoked upon closing the dialog. */ @@ -69,6 +77,21 @@ export interface IResetRevertDialogState { * Boolean indicating whether component buttons should be disabled. */ disabled: boolean; + + /** + * Boolean indicating whether UI interaction should be suspended (e.g., due to pending command). + */ + suspend: boolean; + + /** + * Boolean indicating whether to show an alert. + */ + alert: boolean; + + /** + * Log message. + */ + log: ILogMessage; } /** @@ -89,7 +112,13 @@ export class ResetRevertDialog extends React.Component< this.state = { summary: '', description: '', - disabled: false + disabled: false, + suspend: false, + alert: false, + log: { + severity: 'info', + message: '' + } }; } @@ -99,6 +128,20 @@ export class ResetRevertDialog extends React.Component< * @returns React element */ render(): React.ReactElement { + return ( + + {this._renderDialog()} + {this._renderFeedback()} + + ); + } + + /** + * Renders a dialog. + * + * @returns React element + */ + private _renderDialog(): React.ReactElement { const shortCommit = this.props.commit.commit.slice(0, 7); return ( + + + + ); + } + /** * Callback invoked upon updating a commit message summary. * @@ -221,35 +286,124 @@ export class ResetRevertDialog extends React.Component< */ private _onSubmit = async (): Promise => { const shortCommit = this.props.commit.commit.slice(0, 7); + let err: Error; + this.setState({ disabled: true }); if (this.props.action === 'reset') { + this._log({ + severity: 'info', + message: 'Discarding changes...' + }); + this._suspend(true); try { - await this.props.model.resetToCommit(this.props.commit.commit); - } catch (err) { + await Promise.all([ + sleep(1000), + this.props.model.resetToCommit(this.props.commit.commit) + ]); + } catch (error) { + err = error; + } + this._suspend(false); + if (err) { + this._log({ + severity: 'error', + message: 'Failed to discard changes.' + }); showErrorMessage( - 'Error Removing Changes', + 'Error Discarding Changes', `Failed to discard changes after ${shortCommit}: ${err}` ); + } else { + this._log({ + severity: 'success', + message: 'Successfully discarded changes.' + }); } } else { + this._log({ + severity: 'info', + message: 'Reverting changes...' + }); + this._suspend(true); try { - await this.props.model.revertCommit( - this._commitMessage(), - this.props.commit.commit - ); - } catch (err) { + await Promise.all([ + sleep(1000), + this.props.model.revertCommit( + this._commitMessage(), + this.props.commit.commit + ) + ]); + } catch (error) { + err = error; + } + this._suspend(false); + if (err) { + this._log({ + severity: 'error', + message: 'Failed to revert changes.' + }); showErrorMessage( 'Error Reverting Changes', `Failed to revert ${shortCommit}: ${err}` ); + } else { + this._log({ + severity: 'success', + message: 'Successfully reverted changes.' + }); } } this._reset(); this.props.onClose(); }; + /** + * Callback invoked upon clicking on the feedback modal. + * + * @param event - event object + */ + private _onFeedbackModalClick = (): void => { + this._suspend(false); + }; + + /** + * Callback invoked upon closing a feedback alert. + * + * @param event - event object + */ + private _onFeedbackAlertClose = (): void => { + this.setState({ + alert: false + }); + }; + + /** + * Sets the suspension state. + * + * @param bool - boolean indicating whether to suspend UI interaction + */ + private _suspend(bool: boolean): void { + if (this.props.suspend) { + this.setState({ + suspend: bool + }); + } + } + + /** + * Sets the current component log message. + * + * @param msg - log message + */ + private _log(msg: ILogMessage): void { + this.setState({ + alert: true, + log: msg + }); + } + /** * Returns a default commit summary for reverting a commit. * diff --git a/src/components/SinglePastCommitInfo.tsx b/src/components/SinglePastCommitInfo.tsx index dd644802b..cde23e8e6 100644 --- a/src/components/SinglePastCommitInfo.tsx +++ b/src/components/SinglePastCommitInfo.tsx @@ -47,6 +47,11 @@ export interface ISinglePastCommitInfoProps { * Render MIME type registry. */ renderMime: IRenderMimeRegistry; + + /** + * Boolean indicating whether to enable UI suspension. + */ + suspend: boolean; } /** @@ -206,6 +211,7 @@ export class SinglePastCommitInfo extends React.Component< action={this.state.resetRevertAction} model={this.props.model} commit={this.props.commit} + suspend={this.props.suspend} onClose={this._onResetRevertDialogClose} /> diff --git a/src/components/SuspendModal.tsx b/src/components/SuspendModal.tsx new file mode 100644 index 000000000..bd4cc6e69 --- /dev/null +++ b/src/components/SuspendModal.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import Modal from '@material-ui/core/Modal'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import { fullscreenProgressClass } from '../style/SuspendModal'; + +/** + * Interface describing component properties. + */ +export interface ISuspendModalProps { + /** + * Boolean indicating whether to display a modal blocking UI interaction. + */ + open: boolean; + + /** + * Callback invoked upon clicking on a modal. + */ + onClick?: (event?: any) => void; +} + +/** + * React component for rendering a modal blocking UI interaction. + */ +export class SuspendModal extends React.Component { + /** + * Returns a React component for rendering a modal. + * + * @param props - component properties + * @returns React component + */ + constructor(props: ISuspendModalProps) { + super(props); + } + + /** + * Renders the component. + * + * @returns React element + */ + render(): React.ReactElement { + return ( + +
+ +
+
+ ); + } + + /** + * Callback invoked upon clicking on a feedback modal. + * + * @param event - event object + */ + private _onClick = (event: any): void => { + this.props.onClick && this.props.onClick(event); + }; +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index eae5e6873..e3af282da 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -6,6 +6,7 @@ import { } from '@jupyterlab/ui-components'; import * as React from 'react'; import { classes } from 'typestyle'; +import { CommandIDs } from '../commandsAndMenu'; import { branchIcon, desktopIcon, pullIcon, pushIcon } from '../style/icons'; import { spacer, @@ -20,10 +21,12 @@ import { toolbarMenuWrapperClass, toolbarNavClass } from '../style/Toolbar'; -import { IGitExtension } from '../tokens'; +import { IGitExtension, ILogMessage } from '../tokens'; +import { sleep } from '../utils'; import { ActionButton } from './ActionButton'; +import { Alert } from './Alert'; import { BranchMenu } from './BranchMenu'; -import { CommandIDs } from '../gitMenuCommands'; +import { SuspendModal } from './SuspendModal'; /** * Interface describing component properties. @@ -39,6 +42,11 @@ export interface IToolbarProps { */ branching: boolean; + /** + * Boolean indicating whether to enable UI suspension. + */ + suspend: boolean; + /** * Callback to invoke in order to refresh a repository. * @@ -70,6 +78,21 @@ export interface IToolbarState { * Current branch name. */ branch: string; + + /** + * Boolean indicating whether UI interaction should be suspended (e.g., due to pending command). + */ + suspend: boolean; + + /** + * Boolean indicating whether to show an alert. + */ + alert: boolean; + + /** + * Log message. + */ + log: ILogMessage; } /** @@ -91,7 +114,13 @@ export class Toolbar extends React.Component { branchMenu: false, repoMenu: false, repository: repo || '', - branch: repo ? this.props.model.currentBranch.name : '' + branch: repo ? this.props.model.currentBranch.name : '', + suspend: false, + alert: false, + log: { + severity: 'info', + message: '' + } }; } @@ -120,6 +149,7 @@ export class Toolbar extends React.Component { {this._renderTopNav()} {this._renderRepoMenu()} {this._renderBranchMenu()} + {this._renderFeedback()} ); } @@ -218,12 +248,35 @@ export class Toolbar extends React.Component { ) : null} ); } + /** + * Renders a component to provide UI feedback. + * + * @returns React element + */ + private _renderFeedback(): React.ReactElement { + return ( + + + + + ); + } + /** * Adds model listeners. */ @@ -254,28 +307,59 @@ export class Toolbar extends React.Component { }); } + /** + * Sets the suspension state. + * + * @param bool - boolean indicating whether to suspend UI interaction + */ + private _suspend(bool: boolean): void { + if (this.props.suspend) { + this.setState({ + suspend: bool + }); + } + } + + /** + * Sets the current component log message. + * + * @param msg - log message + */ + private _log(msg: ILogMessage): void { + this.setState({ + alert: true, + log: msg + }); + } + /** * Callback invoked upon clicking a button to pull the latest changes. * * @param event - event object + * @returns a promise which resolves upon pulling the latest changes */ private _onPullClick = (): void => { + this._suspend(true); const commands = this.props.model.commands; if (commands) { commands.execute(CommandIDs.gitPull); } + this._suspend(false); }; /** * Callback invoked upon clicking a button to push the latest changes. * * @param event - event object + * @returns a promise which resolves upon pushing the latest changes */ private _onPushClick = (): void => { + this._suspend(true); const commands = this.props.model.commands; if (commands) { commands.execute(CommandIDs.gitPush); } + this._suspend(false); }; /** @@ -306,8 +390,39 @@ export class Toolbar extends React.Component { * Callback invoked upon clicking a button to refresh a repository. * * @param event - event object + * @returns a promise which resolves upon refreshing a repository + */ + private _onRefreshClick = async (): Promise => { + this._log({ + severity: 'info', + message: 'Refreshing...' + }); + this._suspend(true); + await Promise.all([sleep(1000), this.props.refresh()]); + this._suspend(false); + this._log({ + severity: 'success', + message: 'Successfully refreshed.' + }); + }; + + /** + * Callback invoked upon clicking on the feedback modal. + * + * @param event - event object */ - private _onRefreshClick = (): void => { - this.props.refresh(); + private _onFeedbackModalClick = (): void => { + this._suspend(false); + }; + + /** + * Callback invoked upon closing a feedback alert. + * + * @param event - event object + */ + private _onFeedbackAlertClose = (): void => { + this.setState({ + alert: false + }); }; } diff --git a/src/index.ts b/src/index.ts index bf801893e..9d4632df0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,35 +4,21 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { IChangedArgs } from '@jupyterlab/coreutils'; -import { ISettingRegistry } from '@jupyterlab/settingregistry'; -import { - FileBrowser, - FileBrowserModel, - IFileBrowserFactory -} from '@jupyterlab/filebrowser'; +import { FileBrowserModel, IFileBrowserFactory } from '@jupyterlab/filebrowser'; import { IMainMenu } from '@jupyterlab/mainmenu'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; -import { Menu } from '@lumino/widgets'; -import { addCommands, CommandIDs } from './gitMenuCommands'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { IStatusBar } from '@jupyterlab/statusbar'; +import { addCommands, createGitMenu } from './commandsAndMenu'; import { GitExtension } from './model'; +import { gitIcon } from './style/icons'; import { IGitExtension } from './tokens'; import { addCloneButton } from './widgets/gitClone'; import { GitWidget } from './widgets/GitWidget'; -import { gitIcon } from './style/icons'; +import { addStatusBarWidget } from './widgets/StatusWidget'; export { Git, IGitExtension } from './tokens'; -const RESOURCES = [ - { - text: 'Set Up Remotes', - url: 'https://www.atlassian.com/git/tutorials/setting-up-a-repository' - }, - { - text: 'Git Documentation', - url: 'https://git-scm.com/doc' - } -]; - /** * The default running sessions extension. */ @@ -43,7 +29,8 @@ const plugin: JupyterFrontEndPlugin = { ILayoutRestorer, IFileBrowserFactory, IRenderMimeRegistry, - ISettingRegistry + ISettingRegistry, + IStatusBar ], provides: IGitExtension, activate, @@ -64,7 +51,8 @@ async function activate( restorer: ILayoutRestorer, factory: IFileBrowserFactory, renderMime: IRenderMimeRegistry, - settingRegistry: ISettingRegistry + settingRegistry: ISettingRegistry, + statusBar: IStatusBar ): Promise { let settings: ISettingRegistry.ISettings; @@ -96,6 +84,9 @@ async function activate( // Provided we were able to load application settings, create the extension widgets if (settings) { + // Add JupyterLab commands + addCommands(app, gitExtension, factory.defaultBrowser, settings); + // Create the Git widget sidebar const gitPlugin = new GitWidget( gitExtension, @@ -117,60 +108,14 @@ async function activate( app.shell.add(gitPlugin, 'left', { rank: 200 }); // Add a menu for the plugin - mainMenu.addMenu( - createGitMenu(app, gitExtension, factory.defaultBrowser, settings), - { rank: 60 } - ); - } - // Add a clone button to the file browser extension toolbar - addCloneButton(gitExtension, factory.defaultBrowser); + mainMenu.addMenu(createGitMenu(app.commands), { rank: 60 }); - return gitExtension; -} + // Add a clone button to the file browser extension toolbar + addCloneButton(gitExtension, factory.defaultBrowser); -/** - * Add commands and menu items - */ -function createGitMenu( - app: JupyterFrontEnd, - gitExtension: IGitExtension, - fileBrowser: FileBrowser, - settings: ISettingRegistry.ISettings -): Menu { - const { commands } = app; - addCommands(app, gitExtension, fileBrowser, settings); - - const menu = new Menu({ commands }); - menu.title.label = 'Git'; - [ - CommandIDs.gitInit, - CommandIDs.gitClone, - CommandIDs.gitPush, - CommandIDs.gitPull, - CommandIDs.gitAddRemote, - CommandIDs.gitTerminalCommand - ].forEach(command => { - menu.addItem({ command }); - }); - - menu.addItem({ type: 'separator' }); - - menu.addItem({ command: CommandIDs.gitToggleSimpleStaging }); - - menu.addItem({ command: CommandIDs.gitToggleDoubleClickDiff }); - - menu.addItem({ type: 'separator' }); - - const tutorial = new Menu({ commands }); - tutorial.title.label = ' Help '; - RESOURCES.map(args => { - tutorial.addItem({ - args, - command: CommandIDs.gitOpenUrl - }); - }); - - menu.addItem({ type: 'submenu', submenu: tutorial }); + // Add the status bar widget + addStatusBarWidget(statusBar, gitExtension, settings); + } - return menu; + return gitExtension; } diff --git a/src/model.ts b/src/model.ts index e01844ca7..2c07a8faa 100644 --- a/src/model.ts +++ b/src/model.ts @@ -3,6 +3,7 @@ import { Dialog, showErrorMessage } from '@jupyterlab/apputils'; import { IChangedArgs, PathExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { LinkedList } from '@lumino/collections'; import { CommandRegistry } from '@lumino/commands'; import { JSONObject } from '@lumino/coreutils'; import { Poll } from '@lumino/polling'; @@ -14,8 +15,17 @@ import { decodeStage } from './utils'; // Default refresh interval (in milliseconds) for polling the current Git status (NOTE: this value should be the same value as in the plugin settings schema): const DEFAULT_REFRESH_INTERVAL = 3000; // ms -/** Main extension class */ +/** + * Class for creating a model for retrieving info from, and interacting with, a remote Git repository. + */ export class GitExtension implements IGitExtension { + /** + * Returns an extension model. + * + * @param app - frontend application + * @param settings - plugin settings + * @returns extension model + */ constructor( app: JupyterFrontEnd = null, settings?: ISettingRegistry.ISettings @@ -73,63 +83,54 @@ export class GitExtension implements IGitExtension { } /** - * The list of branch in the current repo + * Branch list for the current repository. */ get branches() { return this._branches; } + /** + * List of available Git commands. + */ get commands(): CommandRegistry | null { return this._app ? this._app.commands : null; } /** - * The current branch + * The current repository branch. */ get currentBranch() { return this._currentBranch; } /** - * A signal emitted when the HEAD of the git repository changes. - */ - get headChanged(): ISignal { - return this._headChanged; - } - - /** - * Get whether the model is disposed. + * Boolean indicating whether the model has been disposed. */ get isDisposed(): boolean { return this._isDisposed; } /** - * Test whether the model is ready. + * Boolean indicating whether the model is ready. */ get isReady(): boolean { return this._pendingReadyPromise === 0; } /** - * A promise that fulfills when the model is ready. + * Promise which fulfills when the model is ready. */ get ready(): Promise { return this._readyPromise; } /** - * A signal emitted when the current marking of the git repository changes. - */ - get markChanged(): ISignal { - return this._markChanged; - } - - /** - * Git Repository path + * Git repository path. + * + * ## Notes * - * This is the top-level folder fullpath. - * null if not defined. + * - This is the full path of the top-level folder. + * - The return value is `null` if a repository path is not defined. */ get pathRepository(): string | null { return this._pathRepository; @@ -169,7 +170,7 @@ export class GitExtension implements IGitExtension { } }) .catch(reason => { - console.error(`Fail to find git top level for path ${v}.\n${reason}`); + console.error(`Fail to find Git top level for path ${v}.\n${reason}`); }); void this._readyPromise.then(() => { @@ -179,247 +180,273 @@ export class GitExtension implements IGitExtension { } /** - * A signal emitted when the current git repository changes. + * The Jupyter front-end application shell. */ - get repositoryChanged(): ISignal> { - return this._repositoryChanged; - } - get shell(): JupyterFrontEnd.IShell | null { return this._app ? this._app.shell : null; } /** - * Files list resulting of a git status call. + * A list of modified files. + * + * ## Notes + * + * - The file list corresponds to the list of files from `git status`. */ get status(): Git.IStatusFile[] { return this._status; } /** - * A signal emitted when the current status of the git repository changes. + * A signal emitted when the `HEAD` of the Git repository changes. + */ + get headChanged(): ISignal { + return this._headChanged; + } + + /** + * A signal emitted when the current marking of the Git repository changes. + */ + get markChanged(): ISignal { + return this._markChanged; + } + + /** + * A signal emitted when the current Git repository changes. + */ + get repositoryChanged(): ISignal> { + return this._repositoryChanged; + } + + /** + * A signal emitted when the current status of the Git repository changes. */ get statusChanged(): ISignal { return this._statusChanged; } /** - * Make request to add one or all files into - * the staging area in repository + * A signal emitted whenever a model event occurs. + */ + get logger(): ISignal { + return this._logger; + } + + /** + * Add one or more files to the repository staging area. + * + * ## Notes * - * If filename is not provided, all files will be added. + * - If no filename is provided, all files are added. * - * @param filename Optional name of the files to add + * @param filename - files to add + * @returns promise which resolves upon adding files to the repository staging area */ async add(...filename: string[]): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); + } + const tid = this._addTask('git:add:files'); + try { + response = await httpGitRequest('/git/add', 'POST', { + add_all: !filename, + filename: filename || '', + top_repo_path: path + }); + } catch (err) { + throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + if (!response.ok) { + const data = await response.json(); + throw new ServerConnection.ResponseError(response, data.message); } - - const response = await httpGitRequest('/git/add', 'POST', { - add_all: !filename, - filename: filename || '', - top_repo_path: path - }); - this.refreshStatus(); return Promise.resolve(response); } /** - * Make request to add all unstaged files into - * the staging area in repository 'path' + * Add all "unstaged" files to the repository staging area. + * + * @returns promise which resolves upon adding files to the repository staging area */ async addAllUnstaged(): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); } - + const tid = this._addTask('git:add:files:all_unstaged'); try { - const response = await httpGitRequest('/git/add_all_unstaged', 'POST', { + response = await httpGitRequest('/git/add_all_unstaged', 'POST', { top_repo_path: path }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - - this.refreshStatus(); - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); } + this.refreshStatus(); + return data; } /** - * Make request to add all untracked files into - * the staging area in the repository + * Add all untracked files to the repository staging area. + * + * @returns promise which resolves upon adding files to the repository staging area */ async addAllUntracked(): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); } - + const tid = this._addTask('git:add:files:all_untracked'); try { - const response = await httpGitRequest('/git/add_all_untracked', 'POST', { + response = await httpGitRequest('/git/add_all_untracked', 'POST', { top_repo_path: path }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - - this.refreshStatus(); - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); + } + this.refreshStatus(); + return data; } /** - * Add file named fname to current marker obj - * - * @param fname Filename - * @param mark Mark to set - */ - addMark(fname: string, mark: boolean) { - this._currentMarker.add(fname, mark); - } - - /** - * get current mark of fname - * - * @param fname Filename - * @returns Mark of the file - */ - getMark(fname: string): boolean { - return this._currentMarker.get(fname); - } - - /** - * Toggle mark for file named fname in current marker obj - * - * @param fname Filename - */ - toggleMark(fname: string) { - this._currentMarker.toggle(fname); - } - - /** - * Add a remote Git repository to the current repository + * Add a remote Git repository to the current repository. * - * @param url Remote repository URL - * @param name Remote name + * @param url - remote repository URL + * @param name - remote name + * @returns promise which resolves upon adding a remote */ async addRemote(url: string, name?: string): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve(); } - + const tid = this._addTask('git:add:remote'); try { - const response = await httpGitRequest('/git/remote/add', 'POST', { + response = await httpGitRequest('/git/remote/add', 'POST', { top_repo_path: path, url, name }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + if (!response.ok) { + const data = await response.text(); + throw new ServerConnection.ResponseError(response, data); } } /** - * Make request for all git info of the repository - * (This API is also implicitly used to check if the current repo is a Git repo) + * Retrieve the repository commit log. + * + * ## Notes * - * @param historyCount: Optional number of commits to get from git log - * @returns Repository history + * - This API can be used to implicitly check if the current folder is a Git repository. + * + * @param count - number of commits to retrieve + * @returns promise which resolves upon retrieving the repository commit log */ - async allHistory(historyCount = 25): Promise { + async allHistory(count = 25): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve({ code: -1, - message: 'Not in a git repository.' + message: 'Not in a Git repository.' }); } - + const tid = this._addTask('git:fetch:history'); try { - const response = await httpGitRequest('/git/all_history', 'POST', { + response = await httpGitRequest('/git/all_history', 'POST', { current_path: path, - history_count: historyCount + history_count: count }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + if (!response.ok) { + const data = await response.text(); + throw new ServerConnection.ResponseError(response, data); } + return response.json(); } /** - * Make request to switch current working branch, - * create new branch if needed, - * or discard a specific file change or all changes - * TODO: Refactor into seperate endpoints for each kind of checkout request + * Checkout a branch. * - * If a branch name is provided, check it out (with or without creating it) - * If a filename is provided, check the file out - * If nothing is provided, check all files out + * ## Notes * - * @param options Checkout options + * - If a branch name is provided, checkout the provided branch (with or without creating it) + * - If a filename is provided, checkout the file, discarding all changes. + * - If nothing is provided, checkout all files, discarding all changes. + * + * TODO: Refactor into separate endpoints for each kind of checkout request + * + * @param options - checkout options + * @returns promise which resolves upon performing a checkout */ async checkout(options?: Git.ICheckoutOptions): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve({ code: -1, - message: 'Not in a git repository.' + message: 'Not in a Git repository.' }); } - const body = { checkout_branch: false, new_check: false, @@ -429,7 +456,6 @@ export class GitExtension implements IGitExtension { filename: '', top_repo_path: path }; - if (options !== undefined) { if (options.branchname) { body.branchname = options.branchname; @@ -443,616 +469,784 @@ export class GitExtension implements IGitExtension { body.checkout_all = false; } } - + const tid = this._addTask('git:checkout'); try { - const response = await httpGitRequest('/git/checkout', 'POST', body); - if (!response.ok) { - return response.json().then((data: any) => { - throw new ServerConnection.ResponseError(response, data.message); - }); - } - - if (body.checkout_branch) { - await this.refreshBranch(); - this._headChanged.emit(); - } else { - this.refreshStatus(); - } - return response.json(); + response = await httpGitRequest('/git/checkout', 'POST', body); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); + } + if (body.checkout_branch) { + await this.refreshBranch(); + this._headChanged.emit(); + } else { + this.refreshStatus(); } + return data; } /** - * Make request for the Git Clone API. + * Clone a repository. * - * @param path Local path in which the repository will be cloned - * @param url Distant Git repository URL - * @param auth Optional authentication information for the remote repository + * @param path - local path into which the repository will be cloned + * @param url - Git repository URL + * @param auth - remote repository authentication information + * @returns promise which resolves upon cloning a repository */ async clone( path: string, url: string, auth?: Git.IAuth ): Promise { - try { - const obj: Git.IGitClone = { - current_path: path, - clone_url: url, - auth - }; + let response; - const response = await httpGitRequest('/git/clone', 'POST', obj); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - return response.json(); + const obj: Git.IGitClone = { + current_path: path, + clone_url: url, + auth + }; + const tid = this._addTask('git:clone'); + try { + response = await httpGitRequest('/git/clone', 'POST', obj); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); } + return data; } /** - * Make request to commit all staged files in repository + * Commit all staged file changes. * - * @param message Commit message + * @param message - commit message + * @returns promise which resolves upon committing file changes */ async commit(message: string): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); } - + const tid = this._addTask('git:commit:create'); try { - const response = await httpGitRequest('/git/commit', 'POST', { + response = await httpGitRequest('/git/commit', 'POST', { commit_msg: message, top_repo_path: path }); - if (!response.ok) { - return response.json().then((data: any) => { - throw new ServerConnection.ResponseError(response, data.message); - }); - } - - this.refreshStatus(); - this._headChanged.emit(); - - return response; } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + if (!response.ok) { + const data = await response.json(); + throw new ServerConnection.ResponseError(response, data.message); } + this.refreshStatus(); + this._headChanged.emit(); + return response; } /** - * Get or set Git configuration options + * Get (or set) Git configuration options. * - * @param options Configuration options to set (undefined to get) + * @param options - configuration options to set + * @returns promise which resolves upon either getting or setting configuration options */ async config(options?: JSONObject): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); } - + const tid = this._addTask('git:config:' + (options ? 'set' : 'get')); try { - const method = 'POST'; - const body = { path, options }; - - const response = await httpGitRequest('/git/config', method, body); - - if (!response.ok) { - const jsonData = await response.json(); - throw new ServerConnection.ResponseError(response, jsonData.message); - } - - return response; + response = await httpGitRequest('/git/config', 'POST', { + path, + options + }); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } + if (!response.ok) { + const data = await response.json(); + throw new ServerConnection.ResponseError(response, data.message); + } + return response; } /** - * Make request to revert changes from selected commit + * Revert changes made after a specified commit. * - * @param message Commit message to use for the new repository state - * @param commitId Selected commit ID + * @param message - commit message + * @param hash - commit identifier (hash) + * @returns promise which resolves upon reverting changes */ - async revertCommit(message: string, commitId: string): Promise { + async revertCommit(message: string, hash: string): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); } - + const tid = this._addTask('git:commit:revert'); try { - const response = await httpGitRequest('/git/delete_commit', 'POST', { - commit_id: commitId, + response = await httpGitRequest('/git/delete_commit', 'POST', { + commit_id: hash, top_repo_path: path }); - if (!response.ok) { - return response.json().then((data: any) => { - throw new ServerConnection.ResponseError(response, data.message); - }); - } - await this.commit(message); - return response; } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + if (!response.ok) { + const data = await response.json(); + throw new ServerConnection.ResponseError(response, data.message); } + await this.commit(message); + return response; } /** - * Make request for detailed git commit info of - * commit 'hash' + * Fetch commit information. * - * @param hash Commit hash + * @param hash - commit hash + * @returns promise which resolves upon retrieving commit information */ async detailedLog(hash: string): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve({ code: -1, - message: 'Not in a git repository.' + message: 'Not in a Git repository.' }); } - + const tid = this._addTask('git:fetch:commit_log'); try { - const response = await httpGitRequest('/git/detailed_log', 'POST', { + response = await httpGitRequest('/git/detailed_log', 'POST', { selected_hash: hash, current_path: path }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } - } - - /** - * Dispose of the resources held by the model. - */ - dispose(): void { - if (this.isDisposed) { - return; - } - this._isDisposed = true; - this._poll.dispose(); - Signal.clearData(this); - } - - /** - * Gets the path of the file relative to the Jupyter server root. - * - * If no path is provided, returns the Git repository top folder relative path. - * If no Git repository selected, return null - * - * @param path the file path relative to Git repository top folder - */ - getRelativeFilePath(path?: string): string | null { - if (this.pathRepository === null || this._serverRoot === undefined) { - return null; + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); } - - return PathExt.join( - PathExt.relative(this._serverRoot, this.pathRepository), - path || '' - ); + return data; } /** - * Make request to initialize a new git repository at path 'path' + * Initialize a new Git repository at a specified path. * - * @param path Folder path to initialize as a git repository. + * @param path - path at which initialize a Git repository + * @returns promise which resolves upon initializing a Git repository */ async init(path: string): Promise { + let response; + + const tid = this._addTask('git:init'); try { - const response = await httpGitRequest('/git/init', 'POST', { + response = await httpGitRequest('/git/init', 'POST', { current_path: path }); - if (!response.ok) { - return response.json().then((data: any) => { - throw new ServerConnection.ResponseError(response, data.message); - }); - } - return response; } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + if (!response.ok) { + const data = await response.json(); + throw new ServerConnection.ResponseError(response, data.message); } + return response; } /** - * Make request for git commit logs + * Retrieve commit logs. * - * @param historyCount: Optional number of commits to get from git log + * @param count - number of commits + * @returns promise which resolves upon retrieving commit logs */ - async log(historyCount = 25): Promise { + async log(count = 25): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve({ code: -1, - message: 'Not in a git repository.' + message: 'Not in a Git repository.' }); } - + const tid = this._addTask('git:fetch:log'); try { - const response = await httpGitRequest('/git/log', 'POST', { + response = await httpGitRequest('/git/log', 'POST', { current_path: path, - history_count: historyCount + history_count: count }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); + } + return data; } /** - * Register a new diff provider for specified file types + * Fetch changes from a remote repository. * - * @param filetypes File type list - * @param callback Callback to use for the provided file types + * @param auth - remote authentication information + * @returns promise which resolves upon fetching changes */ - registerDiffProvider(filetypes: string[], callback: Git.IDiffCallback): void { - filetypes.forEach(fileType => { - this._diffProviders[fileType] = callback; - }); - } - - /** Make request for the Git Pull API. */ async pull(auth?: Git.IAuth): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve({ code: -1, - message: 'Not in a git repository.' + message: 'Not in a Git repository.' }); } - + const obj: Git.IPushPull = { + current_path: path, + auth, + cancel_on_conflict: this._settings + ? (this._settings.composite['cancelPullMergeConflict'] as boolean) + : false + }; + const tid = this._addTask('git:pull'); try { - const obj: Git.IPushPull = { - current_path: path, - auth, - cancel_on_conflict: this._settings - ? (this._settings.composite['cancelPullMergeConflict'] as boolean) - : false - }; - - const response = await httpGitRequest('/git/pull', 'POST', obj); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - - this._headChanged.emit(); - - return response.json(); + response = await httpGitRequest('/git/pull', 'POST', obj); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); + } + this._headChanged.emit(); + return data; } - /** Make request for the Git Push API. */ + /** + * Push local changes to a remote repository. + * + * @param auth - remote authentication information + * @returns promise which resolves upon pushing changes + */ async push(auth?: Git.IAuth): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve({ code: -1, - message: 'Not in a git repository.' + message: 'Not in a Git repository.' }); } - + const obj: Git.IPushPull = { + current_path: path, + auth + }; + const tid = this._addTask('git:push'); try { - const obj: Git.IPushPull = { - current_path: path, - auth - }; - - const response = await httpGitRequest('/git/push', 'POST', obj); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - this._headChanged.emit(); - return response.json(); + response = await httpGitRequest('/git/push', 'POST', obj); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); } + this._headChanged.emit(); + return data; } /** - * General Git refresh + * Refresh the repository. + * + * @returns promise which resolves upon refreshing the repository */ async refresh(): Promise { + const tid = this._addTask('git:refresh'); await this.refreshBranch(); await this.refreshStatus(); + this._removeTask(tid); } /** - * Make request for a list of all Git branches + * Refresh the list of repository branches. + * + * @returns promise which resolves upon refreshing repository branches */ async refreshBranch(): Promise { + const tid = this._addTask('git:refresh:branches'); const response = await this._branch(); - if (response.code === 0) { this._branches = response.branches; this._currentBranch = response.current_branch; - if (this._currentBranch) { - // set up the marker obj for the current (valid) repo/branch combination + // Set up the marker obj for the current (valid) repo/branch combination this._setMarker(this.pathRepository, this._currentBranch.name); } } else { this._branches = []; this._currentBranch = null; } + this._removeTask(tid); } /** - * Request Git status refresh + * Refresh the repository status. + * + * @returns promise which resolves upon refreshing the repository status */ async refreshStatus(): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { this._setStatus([]); return Promise.resolve(); } - + const tid = this._addTask('git:refresh:status'); try { - const response = await httpGitRequest('/git/status', 'POST', { + response = await httpGitRequest('/git/status', 'POST', { current_path: path }); - const data = await response.json(); - if (!response.ok) { - console.error(data.message); - // TODO should we notify the user - this._setStatus([]); - } - - this._setStatus( - (data as Git.IStatusResult).files.map(file => { - return { - ...file, - status: decodeStage(file.x, file.y) - }; - }) - ); } catch (err) { + // TODO we should notify the user + this._setStatus([]); console.error(err); - // TODO should we notify the user + return; + } finally { + this._removeTask(tid); + } + const data = await response.json(); + if (!response.ok) { + console.error(data.message); + + // TODO we should notify the user this._setStatus([]); + return; } + this._setStatus( + (data as Git.IStatusResult).files.map(file => { + return { ...file, status: decodeStage(file.x, file.y) }; + }) + ); } /** - * Make request to move one or all files from the staged to the unstaged area + * Move files from the "staged" to the "unstaged" area. + * + * ## Notes * - * @param filename - Path to a file to be reset. Leave blank to reset all + * - If no filename is provided, moves all files from the "staged" to the "unstaged" area. * - * @returns a promise that resolves when the request is complete. + * @param filename - file path to be reset + * @returns promise which resolves upon moving files */ async reset(filename?: string): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); } - + const tid = this._addTask('git:reset:changes'); try { - const response = await httpGitRequest('/git/reset', 'POST', { + response = await httpGitRequest('/git/reset', 'POST', { reset_all: filename === undefined, filename: filename === undefined ? null : filename, top_repo_path: path }); - if (!response.ok) { - return response.json().then((data: any) => { - throw new ServerConnection.ResponseError(response, data.message); - }); - } - - this.refreshStatus(); - return response; } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + if (!response.ok) { + const data = await response.json(); + throw new ServerConnection.ResponseError(response, data.message); } + this.refreshStatus(); + return response; } /** - * Make request to reset to selected commit + * Reset the repository to a specified commit. + * + * ## Notes * - * @param commitId - Git commit specification. Leave blank to reset to HEAD + * - If a commit hash is not provided, resets the repository to `HEAD`. * - * @returns a promise that resolves when the request is complete. + * @param hash - commit identifier (hash) + * @returns promises which resolves upon resetting the repository */ - async resetToCommit(commitId = ''): Promise { + async resetToCommit(hash = ''): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { - return Promise.resolve( - new Response( - JSON.stringify({ - code: -1, - message: 'Not in a git repository.' - }) - ) - ); + response = { + code: -1, + message: 'Not in a Git repository.' + }; + return Promise.resolve(new Response(JSON.stringify(response))); } - + const tid = this._addTask('git:reset:hard'); try { - const response = await httpGitRequest('/git/reset_to_commit', 'POST', { - commit_id: commitId, + response = await httpGitRequest('/git/reset_to_commit', 'POST', { + commit_id: hash, top_repo_path: path }); - if (!response.ok) { - return response.json().then((data: any) => { - throw new ServerConnection.ResponseError(response, data.message); - }); - } - await this.refreshBranch(); - this._headChanged.emit(); - return response; } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } + if (!response.ok) { + const data = await response.json(); + throw new ServerConnection.ResponseError(response, data.message); + } + await this.refreshBranch(); + this._headChanged.emit(); + return response; } - /** Make request for the prefix path of a directory 'path', - * with respect to the root directory of repository + /** + * Retrieve the prefix path of a directory `path` with respect to the root repository directory. + * + * @param path - directory path + * @returns promise which resolves upon retrieving the prefix path */ async showPrefix(path: string): Promise { + let response; + + const tid = this._addTask('git:fetch:prefix_path'); try { - const response = await httpGitRequest('/git/show_prefix', 'POST', { + response = await httpGitRequest('/git/show_prefix', 'POST', { current_path: path }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); + } + return data; } - /** Make request for top level path of repository 'path' */ + /** + * Retrieve the top level repository path. + * + * @param path - current path + * @returns promise which resolves upon retrieving the top level repository path + */ async showTopLevel(path: string): Promise { + let response; + + const tid = this._addTask('git:fetch:top_level_path'); try { - const response = await httpGitRequest('/git/show_top_level', 'POST', { + response = await httpGitRequest('/git/show_top_level', 'POST', { current_path: path }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); + } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); + } + return data; + } + + /** + * Add a file to the current marker object. + * + * @param fname - filename + * @param mark - mark to set + */ + addMark(fname: string, mark: boolean) { + this._currentMarker.add(fname, mark); + } + + /** + * Return the current mark associated with a specified filename. + * + * @param fname - filename + * @returns mark + */ + getMark(fname: string): boolean { + return this._currentMarker.get(fname); + } + + /** + * Toggle the mark for a file in the current marker object + * + * @param fname - filename + */ + toggleMark(fname: string) { + this._currentMarker.toggle(fname); + } + + /** + * Register a new diff provider for specified file types + * + * @param filetypes File type list + * @param callback Callback to use for the provided file types + */ + registerDiffProvider(filetypes: string[], callback: Git.IDiffCallback): void { + filetypes.forEach(fileType => { + this._diffProviders[fileType] = callback; + }); + } + + /** + * Return the path of a file relative to the Jupyter server root. + * + * ## Notes + * + * - If no path is provided, returns the Git repository top folder relative path. + * - If no Git repository selected, returns `null` + * + * @param path - file path relative to the top folder of the Git repository + * @returns relative path + */ + getRelativeFilePath(path?: string): string | null { + if (this.pathRepository === null || this._serverRoot === void 0) { + return null; + } + return PathExt.join( + PathExt.relative(this._serverRoot, this.pathRepository), + path || '' + ); + } + + /** + * Dispose of model resources. + */ + dispose(): void { + if (this.isDisposed) { + return; } + this._isDisposed = true; + this._poll.dispose(); + Signal.clearData(this); } /** - * Make request for a list of all git branches in the repository + * Retrieve a list of repository branches. * - * @returns The repository branches + * @returns promise which resolves upon fetching repository branches */ protected async _branch(): Promise { + let response; + await this.ready; - const path = this.pathRepository; + const path = this.pathRepository; if (path === null) { return Promise.resolve({ code: -1, - message: 'Not in a git repository.' + message: 'Not in a Git repository.' }); } - + const tid = this._addTask('git:fetch:branches'); try { - const response = await httpGitRequest('/git/branch', 'POST', { + response = await httpGitRequest('/git/branch', 'POST', { current_path: path }); - if (!response.ok) { - const data = await response.json(); - throw new ServerConnection.ResponseError(response, data.message); - } - return response.json(); } catch (err) { throw new ServerConnection.NetworkError(err); + } finally { + this._removeTask(tid); } + const data = await response.json(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message); + } + return data; } /** - * Set repository status + * Set the repository status. * - * @param v Repository status + * @param v - repository status */ protected _setStatus(v: Git.IStatusFile[]) { this._status = v; this._statusChanged.emit(this._status); } + /** + * Retrieve the path of the root server directory. + * + * @returns promise which resolves upon retrieving the root server path + */ private async _getServerRoot(): Promise { + let response; try { - const response = await httpGitRequest('/git/server_root', 'GET', null); - if (response.status === 404) { - throw new ServerConnection.ResponseError( - response, - 'Git server extension is unavailable. Please ensure you have installed the ' + - 'JupyterLab Git server extension by running: pip install --upgrade jupyterlab-git. ' + - 'To confirm that the server extension is installed, run: jupyter serverextension list.' - ); - } - const data = await response.json(); - return data['server_root']; - } catch (error) { - if (error instanceof ServerConnection.ResponseError) { - throw error; - } else { - throw new Error(error); - } + response = await httpGitRequest('/git/server_root', 'GET', null); + } catch (err) { + throw new ServerConnection.NetworkError(err); + } + if (response.status === 404) { + throw new ServerConnection.ResponseError( + response, + 'Git server extension is unavailable. Please ensure you have installed the ' + + 'JupyterLab Git server extension by running: pip install --upgrade jupyterlab-git. ' + + 'To confirm that the server extension is installed, run: jupyter serverextension list.' + ); } + const data = await response.json(); + return data['server_root']; } /** - * set marker obj for repo path/branch combination + * Set the marker object for a repository path and branch. + * + * @returns branch marker */ private _setMarker(path: string, branch: string): BranchMarker { this._currentMarker = this._markerCache.get(path, branch); return this._currentMarker; } + /** + * Adds a task to the list of pending model tasks. + * + * @param task - task name + * @returns task identifier + */ + private _addTask(task: string): number { + // Generate a unique task identifier: + const id = this._generateTaskID(); + + // Add the task to our list of pending tasks: + this._taskList.addLast({ + id: id, + task: task + }); + + // If this task is the only task, broadcast the task... + if (this._taskList.length === 1) { + this._logger.emit(task); + } + // Return the task identifier to allow consumers to remove the task once completed: + return id; + } + + /** + * Removes a task from the list of pending model tasks. + * + * @param id - task identifier + */ + private _removeTask(task: number): void { + let node = this._taskList.firstNode; + + // Check the first node... + if (node && node.value.id === task) { + this._taskList.removeNode(node); + } else { + // Walk the task list looking for a task with the provided identifier... + while (node.next) { + node = node.next; + if (node.value && node.value.id === task) { + this._taskList.removeNode(node); + break; + } + } + } + // Check for pending tasks and broadcast the oldest pending task... + if (this._taskList.length === 0) { + this._logger.emit('git:idle'); + } else { + this._logger.emit(this._taskList.first.task); + } + } + + /** + * Generates a unique task identifier. + * + * @returns task identifier + */ + private _generateTaskID(): number { + this._taskID += 1; + return this._taskID; + } + private _status: Git.IStatusFile[] = []; private _pathRepository: string | null = null; private _branches: Git.IBranch[]; @@ -1066,6 +1260,8 @@ export class GitExtension implements IGitExtension { private _readyPromise: Promise = Promise.resolve(); private _pendingReadyPromise = 0; private _poll: Poll; + private _taskList: LinkedList = new LinkedList(); + private _taskID = 0; private _settings: ISettingRegistry.ISettings | null; private _headChanged = new Signal(this); private _markChanged = new Signal(this); @@ -1074,6 +1270,7 @@ export class GitExtension implements IGitExtension { IChangedArgs >(this); private _statusChanged = new Signal(this); + private _logger = new Signal(this); } export class BranchMarker implements Git.IBranchMarker { diff --git a/src/style/NewBranchDialog.ts b/src/style/NewBranchDialog.ts index cdf2d409a..bbd13c6b5 100644 --- a/src/style/NewBranchDialog.ts +++ b/src/style/NewBranchDialog.ts @@ -1,7 +1,7 @@ import { style } from 'typestyle'; export const branchDialogClass = style({ - height: '460px', + minHeight: '460px', width: '400px', color: 'var(--jp-ui-font-color1)!important', @@ -233,6 +233,10 @@ export const listItemBoldTitleClass = style({ fontWeight: 700 }); +export const errorMessageClass = style({ + color: '#ff0000' +}); + export const actionsWrapperClass = style({ padding: '15px!important', diff --git a/src/style/StatusWidget.ts b/src/style/StatusWidget.ts new file mode 100644 index 000000000..9895c6310 --- /dev/null +++ b/src/style/StatusWidget.ts @@ -0,0 +1,5 @@ +import { style } from 'typestyle'; + +export const statusWidgetClass = style({ + lineHeight: '24px' +}); diff --git a/src/style/SuspendModal.ts b/src/style/SuspendModal.ts new file mode 100644 index 000000000..49d14d144 --- /dev/null +++ b/src/style/SuspendModal.ts @@ -0,0 +1,9 @@ +import { style } from 'typestyle'; + +export const fullscreenProgressClass = style({ + position: 'absolute', + top: '50%', + left: '50%', + color: '#ffffff', + textAlign: 'center' +}); diff --git a/src/tokens.ts b/src/tokens.ts index a99234311..19aca455e 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -22,17 +22,22 @@ export interface IGitExtension extends IDisposable { currentBranch: Git.IBranch; /** - * A signal emitted when the HEAD of the git repository changes. + * A signal emitted when the `HEAD` of the Git repository changes. */ readonly headChanged: ISignal; /** - * Top level path of the current git repository + * A signal emitted whenever a model event occurs. + */ + readonly logger: ISignal; + + /** + * Top level path of the current Git repository */ pathRepository: string | null; /** - * A signal emitted when the current git repository changes. + * A signal emitted when the current Git repository changes. */ readonly repositoryChanged: ISignal>; @@ -49,12 +54,12 @@ export interface IGitExtension extends IDisposable { ready: Promise; /** - * Files list resulting of a git status call. + * Files list resulting of a Git status call. */ readonly status: Git.IStatusFileResult[]; /** - * A signal emitted when the current status of the git repository changes. + * A signal emitted when the current status of the Git repository changes. */ readonly statusChanged: ISignal; @@ -114,10 +119,10 @@ export interface IGitExtension extends IDisposable { addRemote(url: string, name?: string): Promise; /** - * Make request for all git info of the repository + * Make request for all Git info of the repository * (This API is also implicitly used to check if the current repo is a Git repo) * - * @param historyCount: Optional number of commits to get from git log + * @param historyCount: Optional number of commits to get from Git log * @returns Repository history */ allHistory(historyCount?: number): Promise; @@ -137,7 +142,7 @@ export interface IGitExtension extends IDisposable { checkout(options?: Git.ICheckoutOptions): Promise; /** - * Make request for the Git Clone API. + * Make request for the Git clone API. * * @param path Local path in which the repository will be cloned * @param url Distant Git repository URL @@ -169,7 +174,7 @@ export interface IGitExtension extends IDisposable { revertCommit(message: string, commitId: string): Promise; /** - * Make request for detailed git commit info of + * Make request for detailed Git commit info of * commit 'hash' * * @param hash Commit hash @@ -189,16 +194,16 @@ export interface IGitExtension extends IDisposable { getRelativeFilePath(path?: string): string | null; /** - * Make request to initialize a new git repository at path 'path' + * Make request to initialize a new Git repository at path 'path' * - * @param path Folder path to initialize as a git repository. + * @param path Folder path to initialize as a Git repository. */ init(path: string): Promise; /** - * Make request for git commit logs + * Make request for Git commit logs * - * @param historyCount: Optional number of commits to get from git log + * @param historyCount: Optional number of commits to get from Git log * @returns Repository logs */ log(historyCount?: number): Promise; @@ -220,17 +225,17 @@ export interface IGitExtension extends IDisposable { push(auth?: Git.IAuth): Promise; /** - * General git refresh + * General Git refresh */ refresh(): Promise; /** - * Make request for a list of all git branches + * Make request for a list of all Git branches */ refreshBranch(): Promise; /** - * Request git status refresh + * Request Git status refresh */ refreshStatus(): Promise; @@ -298,7 +303,7 @@ export namespace Git { } /** Interface for GitShowTopLevel request result, - * has the git root directory inside a repository + * has the Git root directory inside a repository */ export interface IShowTopLevelResult { code: number; @@ -508,3 +513,23 @@ export namespace Git { | 'partially-staged' | null; } + +/** + * Log message severity. + */ +export type Severity = 'error' | 'warning' | 'info' | 'success'; + +/** + * Interface describing a component log message. + */ +export interface ILogMessage { + /** + * Message severity. + */ + severity: Severity; + + /** + * Message text. + */ + message: string; +} diff --git a/src/utils.ts b/src/utils.ts index f1ab4454d..78add8264 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -109,3 +109,13 @@ export function getFileIconClassName(path: string): string { return genericFileIconStyle; } } + +/** + * Returns a promise which resolves after a specified duration. + * + * @param ms - duration (in milliseconds) + * @returns a promise + */ +export function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/widgets/StatusWidget.ts b/src/widgets/StatusWidget.ts new file mode 100644 index 000000000..65511fbf4 --- /dev/null +++ b/src/widgets/StatusWidget.ts @@ -0,0 +1,178 @@ +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { IStatusBar } from '@jupyterlab/statusbar'; +import { Widget } from '@lumino/widgets'; +import { statusWidgetClass } from '../style/StatusWidget'; +import { IGitExtension } from '../tokens'; +import { sleep } from '../utils'; + +/** + * Class for creating a status bar widget. + */ +export class StatusWidget extends Widget { + /** + * Returns a status bar widget. + * + * @returns widget + */ + constructor() { + super(); + this.addClass(statusWidgetClass); + } + + /** + * Sets the current status. + */ + set status(text: string) { + this._status = text; + if (!this._locked) { + this._lock(); + this.refresh(); + } + } + + /** + * Refreshes the status widget. + */ + refresh(): void { + this.node.textContent = 'Git: ' + this._status; + } + + /** + * Locks the status widget to prevent updates. + * + * ## Notes + * + * - This is used to throttle updates in order to prevent "flashing" messages. + */ + async _lock(): Promise { + this._locked = true; + await sleep(500); + this._locked = false; + this.refresh(); + } + + /** + * Boolean indicating whether the status widget is accepting updates. + */ + private _locked = false; + + /** + * Status string. + */ + private _status = ''; +} + +export function addStatusBarWidget( + statusBar: IStatusBar, + model: IGitExtension, + settings: ISettingRegistry.ISettings +): void { + // Add a status bar widget to provide Git status updates: + const statusWidget = new StatusWidget(); + statusBar.registerStatusItem('git-status', { + align: 'left', + item: statusWidget, + isActive: Private.isStatusWidgetActive(settings), + activeStateChanged: settings && settings.changed + }); + model.logger.connect(Private.createEventCallback(statusWidget)); +} +/* eslint-disable no-inner-declarations */ +namespace Private { + /** + * Returns a callback for updating a status widget upon receiving model events. + * + * @private + * @param widget - status widget + * @returns callback + */ + export function createEventCallback(widget: StatusWidget) { + return onEvent; + + /** + * Callback invoked upon a model event. + * + * @private + * @param model - extension model + * @param event - event name + */ + function onEvent(model: IGitExtension, event: string) { + let status; + switch (event) { + case 'git:checkout': + status = 'checking out...'; + break; + case 'git:clone': + status = 'cloning repository...'; + break; + case 'git:commit:create': + status = 'committing changes...'; + break; + case 'git:commit:revert': + status = 'reverting changes...'; + break; + case 'git:idle': + status = 'idle'; + break; + case 'git:init': + status = 'initializing repository...'; + break; + case 'git:pull': + status = 'pulling changes...'; + break; + case 'git:pushing': + status = 'pushing changes...'; + break; + case 'git:refresh': + status = 'refreshing...'; + break; + case 'git:reset:changes': + status = 'resetting changes...'; + break; + case 'git:reset:hard': + status = 'discarding changes...'; + break; + default: + if (/git:add:files/.test(event)) { + status = 'adding files...'; + } else { + status = 'working...'; + } + break; + } + widget.status = status; + } + } + + /** + * Returns a callback which returns a boolean indicating whether the extension should display status updates. + * + * @private + * @param settings - extension settings + * @returns callback + */ + export function isStatusWidgetActive(settings?: ISettingRegistry.ISettings) { + return settings ? isActive : inactive; + + /** + * Returns a boolean indicating that the extension should not display status updates. + * + * @private + * @returns boolean indicating that the extension should not display status updates + */ + function inactive(): boolean { + return false; + } + + /** + * Returns a boolean indicating whether the extension should display status updates. + * + * @private + * @returns boolean indicating whether the extension should display status updates + */ + function isActive(): boolean { + return settings.composite.displayStatus as boolean; + } + } +} +/* eslint-enable no-inner-declarations */ diff --git a/tests/commands.spec.tsx b/tests/commands.spec.tsx index 5e7912de5..545950fa0 100644 --- a/tests/commands.spec.tsx +++ b/tests/commands.spec.tsx @@ -3,7 +3,7 @@ import * as git from '../src/git'; import { GitExtension } from '../src/model'; import { IGitExtension } from '../src/tokens'; -import { CommandIDs, addCommands } from '../src/gitMenuCommands'; +import { CommandIDs, addCommands } from '../src/commandsAndMenu'; import { CommandRegistry } from '@lumino/commands'; import { JupyterFrontEnd } from '@jupyterlab/application'; diff --git a/tests/test-components/BranchMenu.spec.tsx b/tests/test-components/BranchMenu.spec.tsx index b353d776d..1a9f2a2ff 100644 --- a/tests/test-components/BranchMenu.spec.tsx +++ b/tests/test-components/BranchMenu.spec.tsx @@ -119,7 +119,8 @@ describe('BranchMenu', () => { it('should return a new instance', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const menu = new BranchMenu(props); expect(menu).toBeInstanceOf(BranchMenu); @@ -128,7 +129,8 @@ describe('BranchMenu', () => { it('should set the default menu filter to an empty string', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const menu = new BranchMenu(props); expect(menu.state.filter).toEqual(''); @@ -137,7 +139,8 @@ describe('BranchMenu', () => { it('should set the default flag indicating whether to show a dialog to create a new branch to `false`', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const menu = new BranchMenu(props); expect(menu.state.branchDialog).toEqual(false); @@ -157,7 +160,8 @@ describe('BranchMenu', () => { it('should display placeholder text for the menu filter', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const node = component.find('input[type="text"]').first(); @@ -167,7 +171,8 @@ describe('BranchMenu', () => { it('should set a `title` attribute on the input element to filter a branch menu', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const node = component.find('input[type="text"]').first(); @@ -177,7 +182,8 @@ describe('BranchMenu', () => { it('should display a button to clear the menu filter once a filter is provided', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); component.setState({ @@ -190,7 +196,8 @@ describe('BranchMenu', () => { it('should set a `title` on the button to clear the menu filter', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); component.setState({ @@ -206,7 +213,8 @@ describe('BranchMenu', () => { it('should display a button to create a new branch', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const node = component.find('input[type="button"]').first(); @@ -216,7 +224,8 @@ describe('BranchMenu', () => { it('should set a `title` attribute on the button to create a new branch', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const node = component.find('input[type="button"]').first(); @@ -226,7 +235,8 @@ describe('BranchMenu', () => { it('should display a list of branches', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const nodes = component.find(`.${listItemClass}`); @@ -248,7 +258,8 @@ describe('BranchMenu', () => { it('should set a `title` attribute for each displayed branch', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const nodes = component.find(`.${listItemClass}`); @@ -264,7 +275,8 @@ describe('BranchMenu', () => { it('should not, by default, show a dialog to create a new branch', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const node = component.find('NewBranchDialog').first(); @@ -274,7 +286,8 @@ describe('BranchMenu', () => { it('should show a dialog to create a new branch when the flag indicating whether to show the dialog is `true`', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); component.setState({ @@ -300,7 +313,8 @@ describe('BranchMenu', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); const nodes = component.find(`.${listItemClass}`); @@ -317,7 +331,8 @@ describe('BranchMenu', () => { const props = { model: model, - branching: true + branching: true, + suspend: false }; const component = shallow(); const nodes = component.find(`.${listItemClass}`); @@ -349,7 +364,8 @@ describe('BranchMenu', () => { const props = { model: model, - branching: false + branching: false, + suspend: false }; const component = shallow(); @@ -366,7 +382,8 @@ describe('BranchMenu', () => { const props = { model: model, - branching: true + branching: true, + suspend: false }; const component = shallow(); diff --git a/tests/test-components/GitPanel.spec.tsx b/tests/test-components/GitPanel.spec.tsx index b441e5209..14ec5059b 100644 --- a/tests/test-components/GitPanel.spec.tsx +++ b/tests/test-components/GitPanel.spec.tsx @@ -2,7 +2,7 @@ import * as apputils from '@jupyterlab/apputils'; import 'jest'; import { GitExtension as GitModel } from '../../src/model'; import * as git from '../../src/git'; -import { GitPanel, IGitSessionNodeProps } from '../../src/components/GitPanel'; +import { GitPanel, IGitPanelProps } from '../../src/components/GitPanel'; jest.mock('../../src/git'); jest.mock('@jupyterlab/apputils'); @@ -76,7 +76,7 @@ function MockSettings() { describe('GitPanel', () => { describe('#commitStagedFiles()', () => { - const props: IGitSessionNodeProps = { + const props: IGitPanelProps = { model: null, renderMime: null, settings: null, diff --git a/tests/test-components/HistorySideBar.spec.tsx b/tests/test-components/HistorySideBar.spec.tsx index 59450854e..abb1a3507 100644 --- a/tests/test-components/HistorySideBar.spec.tsx +++ b/tests/test-components/HistorySideBar.spec.tsx @@ -21,7 +21,8 @@ describe('HistorySideBar', () => { ], branches: [], model: null, - renderMime: null + renderMime: null, + suspend: false }; test('renders commit nodes', () => { const historySideBar = shallow(); diff --git a/tests/test-components/PastCommitNode.spec.tsx b/tests/test-components/PastCommitNode.spec.tsx index 242303315..0b51cebde 100644 --- a/tests/test-components/PastCommitNode.spec.tsx +++ b/tests/test-components/PastCommitNode.spec.tsx @@ -56,7 +56,8 @@ describe('PastCommitNode', () => { pre_commit: 'pre_commit' }, branches: branches, - renderMime: null + renderMime: null, + suspend: false }; test('Includes commit info', () => { diff --git a/tests/test-components/Toolbar.spec.tsx b/tests/test-components/Toolbar.spec.tsx index 692831531..c23d634dc 100644 --- a/tests/test-components/Toolbar.spec.tsx +++ b/tests/test-components/Toolbar.spec.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { ActionButton } from '../../src/components/ActionButton'; import { Toolbar } from '../../src/components/Toolbar'; import * as git from '../../src/git'; -import { CommandIDs } from '../../src/gitMenuCommands'; +import { CommandIDs } from '../../src/commandsAndMenu'; import { GitExtension } from '../../src/model'; import { pullIcon, pushIcon } from '../../src/style/icons'; import { @@ -97,6 +97,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const el = new Toolbar(props); @@ -107,6 +108,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const el = new Toolbar(props); @@ -117,6 +119,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const el = new Toolbar(props); @@ -138,6 +141,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -151,6 +155,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -166,6 +171,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -179,6 +185,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -194,6 +201,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -208,6 +216,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -222,6 +231,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -235,6 +245,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -248,6 +259,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -261,6 +273,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -286,6 +299,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -298,6 +312,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -332,6 +347,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -367,6 +383,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: async () => {} }; const node = shallow(); @@ -394,6 +411,7 @@ describe('Toolbar', () => { const props = { model: model, branching: false, + suspend: false, refresh: spy }; const node = shallow(); diff --git a/yarn.lock b/yarn.lock index 06901d67a..9f417dbb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1410,6 +1410,11 @@ resolved "https://registry.yarnpkg.com/@lumino/algorithm/-/algorithm-1.3.0.tgz#1b8b07b537c1660b9e94cb1607d2e74cbf8aa15e" integrity sha512-j0OFVm3/SpvKnKxHiUt8sgct25x+G97ohdrPRzDS9rHX0SLnx4GEVp5qE8OmzfGoAcY9V6C89F6Gh+PnN4LtiA== +"@lumino/algorithm@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@lumino/algorithm/-/algorithm-1.3.3.tgz#fdf4daa407a1ce6f233e173add6a2dda0c99eef4" + integrity sha512-I2BkssbOSLq3rDjgAC3fzf/zAIwkRUnAh60MO0lYcaFdSGyI15w4K3gwZHGIO0p9cKEiNHLXKEODGmOjMLOQ3g== + "@lumino/application@^1.8.4": version "1.10.0" resolved "https://registry.yarnpkg.com/@lumino/application/-/application-1.10.0.tgz#91b7bee4794614ee9bdcf07fe21961953317dba7" @@ -1419,6 +1424,13 @@ "@lumino/coreutils" "^1.5.0" "@lumino/widgets" "^1.13.0" +"@lumino/collections@^1.2.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@lumino/collections/-/collections-1.3.3.tgz#fa95c826b93ee6e24b3c4b07c8f595312525f8cc" + integrity sha512-vN3GSV5INkgM6tMLd+WqTgaPnQNTY7L/aFUtTOC8TJQm+vg1eSmR4fNXsoGHM3uA85ctSJThvdZr5triu1Iajg== + dependencies: + "@lumino/algorithm" "^1.3.3" + "@lumino/collections@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@lumino/collections/-/collections-1.3.0.tgz#83146171544a0fcc818f2eb4f544a676f556940d" @@ -1548,6 +1560,17 @@ dependencies: "@babel/runtime" "^7.4.4" +"@material-ui/lab@^4.0.0-alpha.54": + version "4.0.0-alpha.54" + resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.54.tgz#f359fac05667549353e5e21e631ae22cb2c22996" + integrity sha512-BK/z+8xGPQoMtG6gWKyagCdYO1/2DzkBchvvXs2bbTVh3sbi/QQLIqWV6UA1KtMVydYVt22NwV3xltgPkaPKLg== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.9.6" + clsx "^1.0.4" + prop-types "^15.7.2" + react-is "^16.8.0" + "@material-ui/styles@^4.10.0": version "4.10.0" resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.10.0.tgz#2406dc23aa358217aa8cc772e6237bd7f0544071"