From dafc4006d5d3df5b07d4ac0ef045a73ea41577da Mon Sep 17 00:00:00 2001 From: Frederic Collonval Date: Sun, 26 Jul 2020 15:29:31 +0200 Subject: [PATCH] Merge PR #630 "Provide UI feedback during Git command execution" commit 1c2cb43d453d50f2dcf3cb3b55e752f60516de3c Author: Frederic Collonval Date: Fri Jul 24 18:24:42 2020 +0200 Post Merge branch 'master' corrections commit 5ec36d7e61c4e4ddc0d94711eeaabf774d18aa98 Author: Frederic Collonval Date: Fri Jul 24 17:41:07 2020 +0200 Merge with master commit ae71d8d6011875c37b3eb46d307bafff9a44793f Author: Athan Reines Date: Sun Jun 14 23:30:28 2020 -0700 Use finally blocks commit 2cd0408975dd3859270042173692f73cdde83d90 Author: Athan Reines Date: Sun Jun 14 23:21:23 2020 -0700 Reorder properties and methods commit 9f2d7ca71c40d605cc84ef18fdb54e57dc23686c Author: Athan Reines Date: Mon Jun 8 11:08:57 2020 -0700 Fix response consumption bug commit e5d86d76500b132fd20ee66598cf8796a866d6d3 Merge: cab4132 6169af0 Author: Athan Reines Date: Mon Jun 8 10:33:38 2020 -0700 Merge branch 'master' of https://github.com/jupyterlab/jupyterlab-git into ui-feedback commit cab4132664c082e173cda43f34e4df26dfb1ab4f Author: Athan Reines Date: Mon Jun 1 03:30:55 2020 -0700 Fix broken tests commit c4f08138e74ad8ddb566c6659882675fb1ebbb8d Author: Athan Reines Date: Mon Jun 1 03:30:02 2020 -0700 Fix broken tests commit c132ceda74e56f25cb7e511ea4d8ab1d02e87312 Author: Athan Reines Date: Mon Jun 1 03:06:07 2020 -0700 Add private annotation commit 28be7d5f7b9b56c34e05f0c9d5de9de3a00d27f5 Author: Athan Reines Date: Mon Jun 1 03:04:40 2020 -0700 Reorder methods commit f2d40d817f1746d12208d4ec4ab8e27f700158e1 Author: Athan Reines Date: Mon Jun 1 02:57:31 2020 -0700 Refactor to support UI feedback commit b4d55132b71fead84de9fc550dac2d62fd9719f9 Author: Athan Reines Date: Mon Jun 1 02:22:26 2020 -0700 Fix broken test commit ab68b01c249521244a619fef49bbf175c58e1505 Author: Athan Reines Date: Mon Jun 1 02:13:36 2020 -0700 Refactor to support toast alerts commit 99d676614c3c1ef222bafdb9cc07b37b9d08f682 Author: Athan Reines Date: Mon Jun 1 02:00:47 2020 -0700 Refactor to support toast alerts commit b1d79d6c04c7db857d154f7a86d078970daa7389 Merge: 3daf4ba 8e79eae Author: Athan Reines Date: Mon Jun 1 01:44:23 2020 -0700 Merge branch 'master' of https://github.com/jupyterlab/jupyterlab-git into ui-feedback commit 3daf4ba1afef480c9236f1f8c4387bcc8c2f3398 Author: Athan Reines Date: Mon Jun 1 01:33:04 2020 -0700 Refactor to support log message alerts commit 2d48ddaecf3d8943f8a729162a9b0c0ac3b7f122 Author: Athan Reines Date: Mon Jun 1 01:18:06 2020 -0700 Rename file and refactor into smaller components to support toasts commit d2037385bf8164ef9640ddebba0e6ac957748fe3 Author: Athan Reines Date: Wed May 27 16:11:15 2020 -0700 Refactor to use "toast" notifications commit 1d5c3647c1eb63e3a33c245836942c41d1800c83 Author: Athan Reines Date: Wed May 27 16:10:17 2020 -0700 Add Material UI dep for displaying alert messages within toast notifications commit e0e6a5e4ee2317a459e03bfe9d3767342acee64b Author: Athan Reines Date: Wed May 27 13:34:04 2020 -0700 Add support for providing UI feedback when committing changes commit bf5c52ae8a55d6431b673e458b2549db93da24c1 Author: Athan Reines Date: Wed May 27 10:42:28 2020 -0700 Fix broken tests commit 4d849965fe849adaec7c46685302893ccbb28c29 Author: Athan Reines Date: Wed May 27 10:40:37 2020 -0700 Add src documentation and rename variables to be in line with project conventions commit 018444bab5eca5fb6b5298959cdbfd23fbc5ec56 Author: Athan Reines Date: Tue May 26 17:57:08 2020 -0700 Fix broken tests commit 29b99173047ba478ed6343741397eeb80b3dee1f Author: Athan Reines Date: Tue May 26 17:54:23 2020 -0700 Add support for providing feedback when creating a new branch and refactor error handling commit 126389f38b42fc7b5c4553f5c60bd1476f4e86de Author: Athan Reines Date: Tue May 26 17:13:08 2020 -0700 Provide UI feedback when switching branches commit b1ec72530b6a743eae3126469d35ab70520d3512 Author: Athan Reines Date: Tue May 26 16:01:19 2020 -0700 Add backticks commit fc2edb09e848eb76e396c2d51ee8321b69f9cf55 Merge: 62d88de c34abef Author: Athan Reines Date: Tue May 26 15:55:28 2020 -0700 Merge branch 'master' of https://github.com/jupyterlab/jupyterlab-git into ui-feedback commit 62d88de7904923e27ff6f937eb4b3aad86f5533c Author: Athan Reines Date: Tue May 26 15:41:25 2020 -0700 Document function commit 1596dee22ac902d9af37f113b6949575eefb5d55 Author: Athan Reines Date: Tue May 26 15:39:47 2020 -0700 Remove console.log commit 697bab84d9eaf9e44da9fd2a2d248f7e613a1e47 Author: Athan Reines Date: Tue May 26 15:32:06 2020 -0700 Add support for toggling display of status bar updates commit 2b9da5e8a27d5d9ab4a1af01c5e6b8202b48837a Author: Athan Reines Date: Tue May 26 14:45:30 2020 -0700 Add setting to toggle status bar updates commit c51a449f6614a3f6360744932672e81a12929a7a Author: Athan Reines Date: Mon May 18 22:00:56 2020 -0700 Allow the user to dismiss the modal I am not convinced this is an entirely good idea, as why bother with a blocking modal at all if a user can dismiss. The main hope is that less technical users will simply respect the modal and wait until completion before moving on. The risk is that users will catch on and disregard the warning, allowing for potential footguns. However, reviewers appear hostile to a blocking modal which cannot be readily dismissed. commit b0a1c44bb157b1bff67999b0c60519fedac3c30b Author: Athan Reines Date: Mon May 18 21:48:45 2020 -0700 Fix operation order bug commit 062a2a1ae0de1c54ebef6ef05e25d323af5d9a3f Author: Athan Reines Date: Mon May 18 21:40:06 2020 -0700 Move status widget to separate file and add widget style commit c34c33e9b7cf761ed042b9951b518a4171d8bea3 Author: Athan Reines Date: Mon May 18 21:22:16 2020 -0700 Update lockfile commit 5576d73eb07c1b4abeaf8e133b8b246d45d54c7d Author: Athan Reines Date: Mon May 18 21:20:55 2020 -0700 Restore prior refresh status behavior when non-200 response commit 6bdba114013de2b754fc9180c4a477a4a3fba003 Author: Athan Reines Date: Mon May 18 21:06:44 2020 -0700 Throttle status widget to prevent flashing updates commit cf3d9ac80fbd6bad5cbe375dc910b34127c415d0 Author: Athan Reines Date: Mon May 18 19:57:07 2020 -0700 Update task names and map log events to status messages commit 8d471f42c3a9fc44b714089fa9c693480d5a761c Author: Athan Reines Date: Mon May 18 19:29:12 2020 -0700 Fix task queue management commit ccbfb7008636253972f33cc4fb270e77ae22cfd1 Author: Athan Reines Date: Mon May 18 18:29:02 2020 -0700 Add lumino collections dependency commit 2f155e5b52c78a22e7a4f7e0f3b511ffdfc4637c Author: Athan Reines Date: Mon May 18 18:28:25 2020 -0700 Refactor and clean-up the extension model commit d373478e61197f59656bbb0271c109819717b70c Author: Athan Reines Date: Mon May 18 11:26:12 2020 -0700 Wire up model event logging commit 3b3b915950a50760732fa8d4d31ca6599bf437e0 Author: Athan Reines Date: Mon May 18 11:12:57 2020 -0700 Fix capitalization commit 0169c2a06a527c0727c516dc63ce0a8b1bd8f340 Merge: a579a42 e923a5b Author: Athan Reines Date: Mon May 18 00:51:45 2020 -0700 Merge branch 'master' of https://github.com/jupyterlab/jupyterlab-git into ui-feedback commit a579a4299c4b4c58bf6e7134c9ba0352b5976edb Author: Athan Reines Date: Mon May 18 00:50:59 2020 -0700 Fix capitalization commit 71442cb40d6d5ec9c3b51fa5a72dde3b1e9c5df5 Author: Athan Reines Date: Mon May 18 00:48:38 2020 -0700 Fix capitalization commit f3e25ec3084b93c94f1cb76145f3a4e15a5667e8 Author: Athan Reines Date: Mon May 18 00:32:52 2020 -0700 Fix broken tests commit f58b51ff5ae7780e73cd586c71c7bb678f0b7160 Author: Athan Reines Date: Mon May 18 00:23:45 2020 -0700 Toggle UI suspension based on plugin setting commit 6a93b3497f3aea4da2fcfcdf93649fb0fc57d16f Author: Athan Reines Date: Mon May 18 00:23:05 2020 -0700 Update setting description commit 1e364700ddb0bde6fb881222d55128990330ae8d Author: Athan Reines Date: Mon May 18 00:22:24 2020 -0700 Update description commit 4437e603e5ef66abb576245836e235a8e306ccc6 Author: Athan Reines Date: Mon May 11 11:22:06 2020 -0700 Document new setting commit 1bace21574c5c830f397eab0b6724980e0cf6639 Author: Athan Reines Date: Mon May 11 11:20:34 2020 -0700 Document setting commit da2ec0e1edc2c109402b97ad612ed9fc0dd16875 Author: Athan Reines Date: Mon May 11 11:18:38 2020 -0700 Add setting for toggling UI suspension commit 4ced5a89083570e34387be1cb88fdf1a9361395b Author: Athan Reines Date: Mon May 4 14:50:46 2020 -0700 Ensure a minimum duration commit 06300ff684864b56620bc345b2dbf7564fef61f0 Author: Athan Reines Date: Thu Apr 30 16:50:32 2020 -0700 Add UI feedback during toolbar actions --- README.md | 5 +- package.json | 2 + schema/plugin.json | 14 +- ...{gitMenuCommands.ts => commandsAndMenu.ts} | 61 +- src/components/Alert.tsx | 127 ++ src/components/BranchMenu.tsx | 335 +++-- src/components/GitPanel.tsx | 257 +++- src/components/HistorySideBar.tsx | 6 + src/components/NewBranchDialog.tsx | 218 ++- src/components/PastCommitNode.tsx | 6 + src/components/ResetRevertDialog.tsx | 174 ++- src/components/SinglePastCommitInfo.tsx | 6 + src/components/SuspendModal.tsx | 58 + src/components/Toolbar.tsx | 125 +- src/index.ts | 95 +- src/model.ts | 1167 ++++++++++------- src/style/NewBranchDialog.ts | 6 +- src/style/StatusWidget.ts | 5 + src/style/SuspendModal.ts | 9 + src/tokens.ts | 59 +- src/utils.ts | 10 + src/widgets/StatusWidget.ts | 178 +++ tests/commands.spec.tsx | 2 +- tests/test-components/BranchMenu.spec.tsx | 51 +- tests/test-components/GitPanel.spec.tsx | 4 +- tests/test-components/HistorySideBar.spec.tsx | 3 +- tests/test-components/PastCommitNode.spec.tsx | 3 +- tests/test-components/Toolbar.spec.tsx | 20 +- yarn.lock | 23 + 29 files changed, 2242 insertions(+), 787 deletions(-) rename src/{gitMenuCommands.ts => commandsAndMenu.ts} (84%) create mode 100644 src/components/Alert.tsx create mode 100644 src/components/SuspendModal.tsx create mode 100644 src/style/StatusWidget.ts create mode 100644 src/style/SuspendModal.ts create mode 100644 src/widgets/StatusWidget.ts 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"