From 115520135d5574fa44170e3df0176006e671f157 Mon Sep 17 00:00:00 2001 From: Frederic Collonval Date: Mon, 30 Dec 2019 14:07:40 +0100 Subject: [PATCH 1/6] Functional pin repository --- LICENSE | 26 ++++ src/components/GitPanel.tsx | 3 + src/components/PathHeader.tsx | 237 +++++++++++++++++++++++----------- src/index.ts | 41 ++++-- src/model.ts | 104 +++++++++++++-- src/style/GitPanelStyle.ts | 5 +- src/style/GitWidgetStyle.ts | 10 -- src/style/PathHeaderStyle.ts | 68 +++++++++- src/style/icons.ts | 5 + src/tokens.ts | 22 +++- src/widgets/GitWidget.tsx | 51 +++----- style/images/pin.svg | 4 + 12 files changed, 428 insertions(+), 148 deletions(-) delete mode 100644 src/style/GitWidgetStyle.ts create mode 100644 style/images/pin.svg diff --git a/LICENSE b/LICENSE index 0dc89813d..27637dcc7 100644 --- a/LICENSE +++ b/LICENSE @@ -27,3 +27,29 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +This software makes use of octicons (https://github.com/primer/octicons) licensed under + +MIT License + +Copyright (c) 2019 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 6cfee14c3..6145239ec 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -17,6 +17,7 @@ import { FileList } from './FileList'; import { HistorySideBar } from './HistorySideBar'; import { PathHeader } from './PathHeader'; import { CommitBox } from './CommitBox'; +import { FileBrowserModel } from '@jupyterlab/filebrowser'; /** Interface for GitPanel component state */ export interface IGitSessionNodeState { @@ -40,6 +41,7 @@ export interface IGitSessionNodeProps { model: GitExtension; renderMime: IRenderMimeRegistry; settings: ISettingRegistry.ISettings; + fileBrowserModel: FileBrowserModel; } /** A React component for the git extension's main display */ @@ -290,6 +292,7 @@ export class GitPanel extends React.Component<
{ await this.refreshBranch(); if (this.state.isHistoryVisible) { diff --git a/src/components/PathHeader.tsx b/src/components/PathHeader.tsx index 1451b194e..abdda586c 100644 --- a/src/components/PathHeader.tsx +++ b/src/components/PathHeader.tsx @@ -1,13 +1,18 @@ import { Dialog, showDialog, UseSignal } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; +import { FileDialog, FileBrowserModel } from '@jupyterlab/filebrowser'; +import { DefaultIconReact } from '@jupyterlab/ui-components'; import * as React from 'react'; import { classes } from 'typestyle'; import { gitPullStyle, gitPushStyle, + pinIconStyle, repoPathStyle, repoRefreshStyle, - repoStyle + repoStyle, + repoPinStyle, + repoPinnedStyle } from '../style/PathHeaderStyle'; import { GitCredentialsForm } from '../widgets/CredentialsBox'; import { GitPullPushDialog, Operation } from '../widgets/gitPushPull'; @@ -15,103 +20,181 @@ import { IGitExtension } from '../tokens'; export interface IPathHeaderProps { model: IGitExtension; + fileBrowserModel: FileBrowserModel; refresh: () => Promise; } -export class PathHeader extends React.Component { - constructor(props: IPathHeaderProps) { - super(props); +/** + * Displays the error dialog when the Git Push/Pull operation fails. + * @param title the title of the error dialog + * @param body the message to be shown in the body of the modal. + */ +async function showGitPushPullDialog( + model: IGitExtension, + operation: Operation +): Promise { + let result = await showDialog({ + title: `Git ${operation}`, + body: new GitPullPushDialog(model, operation), + buttons: [Dialog.okButton({ label: 'DISMISS' })] + }); + let retry = false; + while (!result.button.accept) { + retry = true; + + let response = await showDialog({ + title: 'Git credentials required', + body: new GitCredentialsForm( + 'Enter credentials for remote repository', + retry ? 'Incorrect username or password.' : '' + ), + buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'OK' })] + }); + + if (response.button.accept) { + // user accepted attempt to login + result = await showDialog({ + title: `Git ${operation}`, + body: new GitPullPushDialog(model, operation, response.value), + buttons: [Dialog.okButton({ label: 'DISMISS' })] + }); + } else { + break; + } + } +} + +/** + * Select a Git repository folder + * + * @param model Git extension model + * @param fileModel file browser model + */ +async function selectGitRepository( + model: IGitExtension, + fileModel: FileBrowserModel +) { + const result = await FileDialog.getExistingDirectory({ + iconRegistry: fileModel.iconRegistry, + manager: fileModel.manager, + title: 'Select a Git repository folder' + }); + + if (result.button.accept) { + const folder = result.value[0]; + if (model.repositoryPinned) { + model.pathRepository = folder.path; + } else if (fileModel.path !== folder.path) { + // Change current filebrowser path + fileModel.cd('/' + folder.path); + } + } +} + +/** + * React function component to render the toolbar and path header component + * + * @param props Properties for the path header component + */ +export const PathHeader: React.FunctionComponent = ( + props: IPathHeaderProps +) => { + const [pin, setPin] = React.useState(props.model.repositoryPinned); + + React.useEffect(() => { + props.model.restored.then(() => { + setPin(props.model.repositoryPinned); + }); + }); + + const pinStyles = [repoPinStyle, 'jp-Icon-16']; + if (pin) { + pinStyles.push(repoPinnedStyle); } - render() { - return ( + return ( +
- - {(_, change) => ( - - {PathExt.basename(change.newValue || '')} - - )} -
- ); - } - - /** - * Displays the error dialog when the Git Push/Pull operation fails. - * @param title the title of the error dialog - * @param body the message to be shown in the body of the modal. - */ - private async showGitPushPullDialog( - model: IGitExtension, - operation: Operation - ): Promise { - let result = await showDialog({ - title: `Git ${operation}`, - body: new GitPullPushDialog(model, operation), - buttons: [Dialog.okButton({ label: 'DISMISS' })] - }); - let retry = false; - while (!result.button.accept) { - retry = true; - - let response = await showDialog({ - title: 'Git credentials required', - body: new GitCredentialsForm( - 'Enter credentials for remote repository', - retry ? 'Incorrect username or password.' : '' - ), - buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'OK' })] - }); +
+ + {(_, change) => { + const pathStyles = [repoPathStyle]; + let pinTitle = 'the repository path'; + if (props.model.repositoryPinned) { + pinTitle = 'Unpin ' + pinTitle; + } else { + pinTitle = 'Pin ' + pinTitle; + } - if (response.button.accept) { - // user accepted attempt to login - result = await showDialog({ - title: `Git ${operation}`, - body: new GitPullPushDialog(model, operation, response.value), - buttons: [Dialog.okButton({ label: 'DISMISS' })] - }); - } else { - break; - } - } - } -} + return ( + + + { + selectGitRepository(props.model, props.fileBrowserModel); + }} + > + {PathExt.basename( + change.newValue || 'Click to select a Git repository.' + )} + + + ); + }} + +
+
+ ); +}; diff --git a/src/index.ts b/src/index.ts index a18ab0f14..2a54ad5ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,11 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { IChangedArgs, ISettingRegistry } from '@jupyterlab/coreutils'; +import { + IChangedArgs, + ISettingRegistry, + IStateDB +} from '@jupyterlab/coreutils'; import { FileBrowser, FileBrowserModel, @@ -14,11 +18,11 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { defaultIconRegistry } from '@jupyterlab/ui-components'; import { Menu } from '@phosphor/widgets'; import { addCommands, CommandIDs } from './gitMenuCommands'; -import { GitExtension } from './model'; +import { GitExtension, PLUGIN_ID } from './model'; import { registerGitIcons } from './style/icons'; import { IGitExtension } from './tokens'; import { addCloneButton } from './widgets/gitClone'; -import { GitWidget } from './widgets/GitWidget'; +import { createGitWidget } from './widgets/GitWidget'; export { Git, IGitExtension } from './tokens'; @@ -37,13 +41,14 @@ const RESOURCES = [ * The default running sessions extension. */ const plugin: JupyterFrontEndPlugin = { - id: '@jupyterlab/git:plugin', + id: PLUGIN_ID, requires: [ IMainMenu, ILayoutRestorer, IFileBrowserFactory, IRenderMimeRegistry, - ISettingRegistry + ISettingRegistry, + IStateDB ], provides: IGitExtension, activate, @@ -64,7 +69,8 @@ async function activate( restorer: ILayoutRestorer, factory: IFileBrowserFactory, renderMime: IRenderMimeRegistry, - settingRegistry: ISettingRegistry + settingRegistry: ISettingRegistry, + state: IStateDB ): Promise { let settings: ISettingRegistry.ISettings; @@ -81,17 +87,25 @@ async function activate( console.error(`Failed to load settings for the Git Extension.\n${error}`); } // Create the Git model - const gitExtension = new GitExtension(app, settings); + const gitExtension = new GitExtension(app, settings, state); // Whenever we restore the application, sync the Git extension path - Promise.all([app.restored, filebrowser.model.restored]).then(() => { - gitExtension.pathRepository = filebrowser.model.path; + Promise.all([ + app.restored, + gitExtension.restored, + filebrowser.model.restored + ]).then(() => { + if (!gitExtension.repositoryPinned) { + gitExtension.pathRepository = filebrowser.model.path; + } }); // Whenever the file browser path changes, sync the Git extension path filebrowser.model.pathChanged.connect( (model: FileBrowserModel, change: IChangedArgs) => { - gitExtension.pathRepository = change.newValue; + if (!gitExtension.repositoryPinned) { + gitExtension.pathRepository = change.newValue; + } } ); // Whenever a user adds/renames/saves/deletes/modifies a file within the lab environment, refresh the Git status @@ -100,7 +114,12 @@ async function activate( // Provided we were able to load application settings, create the extension widgets if (settings) { // Create the Git widget sidebar - const gitPlugin = new GitWidget(gitExtension, settings, renderMime); + const gitPlugin = createGitWidget( + gitExtension, + settings, + renderMime, + filebrowser.model + ); gitPlugin.id = 'jp-git-sessions'; gitPlugin.title.iconClass = 'jp-SideBar-tabIcon jp-GitIcon'; gitPlugin.title.caption = 'Git'; diff --git a/src/model.ts b/src/model.ts index e07725ea5..ab5d607a7 100644 --- a/src/model.ts +++ b/src/model.ts @@ -3,7 +3,8 @@ import { IChangedArgs, PathExt, Poll, - ISettingRegistry + ISettingRegistry, + IStateDB } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import { CommandRegistry } from '@phosphor/commands'; @@ -15,15 +16,67 @@ import { IGitExtension, Git } from './tokens'; // 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 +export const PLUGIN_ID = '@jupyterlab/git:plugin'; + +/** + * State variables of @jupyterlab/git extension + */ +interface IGitState { + /** + * Is the repository path pinned? i.e. not connected to the file browser + */ + isRepositoryPin: boolean; + /** + * Git path repository + */ + pathRepository: string | null; +} + /** Main extension class */ export class GitExtension implements IGitExtension { constructor( app: JupyterFrontEnd = null, - settings?: ISettingRegistry.ISettings + settings?: ISettingRegistry.ISettings, + state?: IStateDB ) { const model = this; this._app = app; + // Load state extension + this._state = { + isRepositoryPin: false, + pathRepository: null + }; + + this._restored = app.restored.then(() => { + if (state) { + return state + .fetch(PLUGIN_ID) + .then(value => { + if (value) { + const stateExtension: IGitState = value as any; + + if (stateExtension.isRepositoryPin) { + const change: IChangedArgs = { + name: 'pathRepository', + newValue: stateExtension.pathRepository, + oldValue: this._state.pathRepository + }; + this._state = stateExtension; + this._repositoryChanged.emit(change); + } + } + }) + .catch(reason => { + console.error( + `Fail to fetch the state for ${PLUGIN_ID}.\n${reason}` + ); + }); + } else { + return Promise.resolve(); + } + }); + // Load the server root path this._getServerRoot() .then(root => { @@ -127,22 +180,25 @@ export class GitExtension implements IGitExtension { * null if not defined. */ get pathRepository(): string | null { - return this._pathRepository; + return this._state.pathRepository; } set pathRepository(v: string | null) { const change: IChangedArgs = { name: 'pathRepository', newValue: null, - oldValue: this._pathRepository + oldValue: this._state.pathRepository }; if (v === null) { this._pendingReadyPromise += 1; this._readyPromise.then(() => { - this._pathRepository = null; + this._state.pathRepository = null; this._pendingReadyPromise -= 1; if (change.newValue !== change.oldValue) { + if (this._stateDB) { + this._stateDB.save(PLUGIN_ID, this._state as any); + } this.refresh().then(() => this._repositoryChanged.emit(change)); } }); @@ -153,13 +209,16 @@ export class GitExtension implements IGitExtension { .then(r => { const results = r[1]; if (results.code === 0) { - this._pathRepository = results.top_repo_path; + this._state.pathRepository = results.top_repo_path; change.newValue = results.top_repo_path; } else { - this._pathRepository = null; + this._state.pathRepository = null; } if (change.newValue !== change.oldValue) { + if (this._stateDB) { + this._stateDB.save(PLUGIN_ID, this._state as any); + } this.refresh().then(() => this._repositoryChanged.emit(change)); } }) @@ -180,6 +239,33 @@ export class GitExtension implements IGitExtension { return this._repositoryChanged; } + /** + * Is the Git repository path pinned? + */ + get repositoryPinned(): boolean { + return this._state.isRepositoryPin; + } + + set repositoryPinned(status: boolean) { + if (this._state.isRepositoryPin !== status) { + this._state.isRepositoryPin = status; + if (this._stateDB) { + this._stateDB + .save(PLUGIN_ID, this._state as any) + .catch(reason => + console.error(`Fail to save the ${PLUGIN_ID} state.\n${reason}`) + ); + } + } + } + + /** + * Promise that resolves when state is first restored. + */ + get restored(): Promise { + return this._restored; + } + get shell(): JupyterFrontEnd.IShell | null { return this._app ? this._app.shell : null; } @@ -994,7 +1080,6 @@ export class GitExtension implements IGitExtension { } private _status: Git.IStatusFileResult[] = []; - private _pathRepository: string | null = null; private _branches: Git.IBranch[]; private _currentBranch: Git.IBranch; private _serverRoot: string; @@ -1012,6 +1097,9 @@ export class GitExtension implements IGitExtension { IGitExtension, IChangedArgs >(this); + private _restored: Promise; + private _state: IGitState; + private _stateDB: IStateDB | null = null; private _statusChanged = new Signal( this ); diff --git a/src/style/GitPanelStyle.ts b/src/style/GitPanelStyle.ts index 1043ef8de..c4a5679ea 100644 --- a/src/style/GitPanelStyle.ts +++ b/src/style/GitPanelStyle.ts @@ -4,7 +4,10 @@ export const panelContainerStyle = style({ display: 'flex', flexDirection: 'column', overflowY: 'auto', - height: '100%' + height: '100%', + color: 'var(--jp-ui-font-color1)', + background: 'var(--jp-layout-color1)', + fontSize: 'var(--jp-ui-font-size1)' }); export const panelWarningStyle = style({ diff --git a/src/style/GitWidgetStyle.ts b/src/style/GitWidgetStyle.ts deleted file mode 100644 index 115a5e042..000000000 --- a/src/style/GitWidgetStyle.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { style } from 'typestyle'; - -export const gitWidgetStyle = style({ - display: 'flex', - flexDirection: 'column', - minWidth: '300px', - color: 'var(--jp-ui-font-color1)', - background: 'var(--jp-layout-color1)', - fontSize: 'var(--jp-ui-font-size1)' -}); diff --git a/src/style/PathHeaderStyle.ts b/src/style/PathHeaderStyle.ts index 347548e3c..34ae11858 100644 --- a/src/style/PathHeaderStyle.ts +++ b/src/style/PathHeaderStyle.ts @@ -1,5 +1,14 @@ import { style } from 'typestyle'; +export const pinIconStyle = style({ + position: 'absolute', + cursor: 'pointer', + top: 0, + left: 0, + right: 0, + bottom: 0 +}); + export const repoStyle = style({ display: 'flex', flexDirection: 'row', @@ -8,6 +17,57 @@ export const repoStyle = style({ minHeight: '35px' }); +export const repoPinStyle = style({ + background: 'var(--jp-layout-color1)', + position: 'relative', + display: 'inline-block', + width: '24px', + height: '24px', + margin: 'auto 5px auto 5px', + + $nest: { + input: { + opacity: 0, + height: 0, + width: 0, + + $nest: { + '&:checked': { + opacity: 10 + } + } + }, + + 'input:checked + svg': { + fill: 'var(--jp-brand-color1)' + }, + + '&:hover': { + backgroundColor: 'var(--jp-layout-color2)' + } + } + // // width: 'var(--jp-private-running-button-width)', + + // border: 'none', + // boxSizing: 'border-box', + // outline: 'none', + // // padding: '0px 6px', + + // $nest: { + // '&:active': { + // backgroundColor: 'var(--jp-layout-color3)' + // } + // } +}); + +export const repoPinnedStyle = style({ + $nest: { + '.jp-icon3': { + fill: 'var(--jp-brand-color1)' + } + } +}); + export const repoPathStyle = style({ fontSize: 'var(--jp-ui-font-size1)', marginRight: '4px', @@ -16,7 +76,13 @@ export const repoPathStyle = style({ overflow: 'hidden', whiteSpace: 'nowrap', verticalAlign: 'middle', - lineHeight: '33px' + lineHeight: '33px', + + $nest: { + '&:hover': { + backgroundColor: 'var(--jp-layout-color2)' + } + } }); export const repoRefreshStyle = style({ diff --git a/src/style/icons.ts b/src/style/icons.ts index c750392d9..a5c5164b3 100644 --- a/src/style/icons.ts +++ b/src/style/icons.ts @@ -4,6 +4,7 @@ import { IIconRegistry } from '@jupyterlab/ui-components'; import gitSvg from '../../style/images/git-icon.svg'; import deletionsMadeSvg from '../../style/images/deletions-made-icon.svg'; import insertionsMadeSvg from '../../style/images/insertions-made-icon.svg'; +import pinSvg from '../../style/images/pin.svg'; export function registerGitIcons(iconRegistry: IIconRegistry) { iconRegistry.addIcon( @@ -18,6 +19,10 @@ export function registerGitIcons(iconRegistry: IIconRegistry) { { name: 'git-insertionsMade', svg: insertionsMadeSvg + }, + { + name: 'git-pin', + svg: pinSvg } ); } diff --git a/src/tokens.ts b/src/tokens.ts index a318b4a06..1572fe2e3 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -25,6 +25,18 @@ export interface IGitExtension extends IDisposable { */ readonly headChanged: ISignal; + /** + * Test whether the model is ready; + * i.e. if the top folder repository has been found. + */ + isReady: boolean; + + /** + * A promise that fulfills when the model is ready; + * i.e. if the top folder repository has been found. + */ + ready: Promise; + /** * Top level path of the current git repository */ @@ -36,16 +48,14 @@ export interface IGitExtension extends IDisposable { readonly repositoryChanged: ISignal>; /** - * Test whether the model is ready; - * i.e. if the top folder repository has been found. + * Is the Git repository path pinned? */ - isReady: boolean; + repositoryPinned: boolean; /** - * A promise that fulfills when the model is ready; - * i.e. if the top folder repository has been found. + * Promise that resolves when state is first restored. */ - ready: Promise; + readonly restored: Promise; /** * Files list resulting of a git status call. diff --git a/src/widgets/GitWidget.tsx b/src/widgets/GitWidget.tsx index de55a8ea8..52a7a3a42 100644 --- a/src/widgets/GitWidget.tsx +++ b/src/widgets/GitWidget.tsx @@ -1,42 +1,25 @@ import { ReactWidget } from '@jupyterlab/apputils'; +import { ISettingRegistry } from '@jupyterlab/coreutils'; +import { FileBrowserModel } from '@jupyterlab/filebrowser'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; -import { Widget } from '@phosphor/widgets'; import * as React from 'react'; import { GitPanel } from '../components/GitPanel'; import { GitExtension } from '../model'; -import { gitWidgetStyle } from '../style/GitWidgetStyle'; -import { ISettingRegistry } from '@jupyterlab/coreutils'; /** - * A class that exposes the git plugin Widget. + * create the git plugin Widget. */ -export class GitWidget extends ReactWidget { - constructor( - model: GitExtension, - settings: ISettingRegistry.ISettings, - renderMime: IRenderMimeRegistry, - options?: Widget.IOptions - ) { - super(options); - this.node.id = 'GitSession-root'; - this.addClass(gitWidgetStyle); - - this._model = model; - this._renderMime = renderMime; - this._settings = settings; - } - - render() { - return ( - - ); - } - - private _model: GitExtension; - private _renderMime: IRenderMimeRegistry; - private _settings: ISettingRegistry.ISettings; -} +export const createGitWidget = ( + model: GitExtension, + settings: ISettingRegistry.ISettings, + renderMime: IRenderMimeRegistry, + fileBrowserModel: FileBrowserModel +) => + ReactWidget.create( + + ); diff --git a/style/images/pin.svg b/style/images/pin.svg new file mode 100644 index 000000000..394c533e8 --- /dev/null +++ b/style/images/pin.svg @@ -0,0 +1,4 @@ + + + + From 76cd29de160fd83286c27cb445c9d2da7e16feeb Mon Sep 17 00:00:00 2001 From: Frederic Collonval Date: Tue, 31 Dec 2019 17:32:56 +0100 Subject: [PATCH 2/6] Remove `not in git repo` --- src/components/GitPanel.tsx | 26 ++++---------------------- src/style/BranchHeaderStyle.ts | 1 - src/style/GitPanelStyle.ts | 11 ----------- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 6145239ec..bf81fa6ef 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -1,23 +1,19 @@ import * as React from 'react'; import { showErrorMessage, showDialog } from '@jupyterlab/apputils'; import { ISettingRegistry } from '@jupyterlab/coreutils'; +import { FileBrowserModel } from '@jupyterlab/filebrowser'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { JSONObject } from '@phosphor/coreutils'; import { GitExtension } from '../model'; -import { - findRepoButtonStyle, - panelContainerStyle, - panelWarningStyle -} from '../style/GitPanelStyle'; +import { panelContainerStyle } from '../style/GitPanelStyle'; import { Git } from '../tokens'; import { decodeStage } from '../utils'; import { GitAuthorForm } from '../widgets/AuthorBox'; import { BranchHeader } from './BranchHeader'; +import { CommitBox } from './CommitBox'; import { FileList } from './FileList'; import { HistorySideBar } from './HistorySideBar'; import { PathHeader } from './PathHeader'; -import { CommitBox } from './CommitBox'; -import { FileBrowserModel } from '@jupyterlab/filebrowser'; /** Interface for GitPanel component state */ export interface IGitSessionNodeState { @@ -201,7 +197,7 @@ export class GitPanel extends React.Component< render() { let filelist: React.ReactElement; - let main: React.ReactElement; + let main: React.ReactElement = null; let sub: React.ReactElement; let msg: React.ReactElement; @@ -272,20 +268,6 @@ export class GitPanel extends React.Component< {sub} ); - } else { - main = ( -
-
You aren’t in a git repository.
- -
- ); } return ( diff --git a/src/style/BranchHeaderStyle.ts b/src/style/BranchHeaderStyle.ts index 752aa01e4..aad4851e3 100644 --- a/src/style/BranchHeaderStyle.ts +++ b/src/style/BranchHeaderStyle.ts @@ -8,7 +8,6 @@ export const branchStyle = style({ }); export const selectedHeaderStyle = style({ - borderTop: 'var(--jp-border-width) solid var(--jp-border-color2)', paddingBottom: 'var(--jp-border-width)' }); diff --git a/src/style/GitPanelStyle.ts b/src/style/GitPanelStyle.ts index c4a5679ea..edc9608bb 100644 --- a/src/style/GitPanelStyle.ts +++ b/src/style/GitPanelStyle.ts @@ -9,14 +9,3 @@ export const panelContainerStyle = style({ background: 'var(--jp-layout-color1)', fontSize: 'var(--jp-ui-font-size1)' }); - -export const panelWarningStyle = style({ - textAlign: 'center', - marginTop: '9px' -}); - -export const findRepoButtonStyle = style({ - color: 'white', - backgroundColor: 'var(--jp-brand-color1)', - marginTop: '5px' -}); From bf84453277763e86123042a1307e31ecc24e490c Mon Sep 17 00:00:00 2001 From: Frederic Collonval Date: Tue, 31 Dec 2019 18:04:41 +0100 Subject: [PATCH 3/6] Style repository path --- LICENSE | 26 ------ src/components/PathHeader.tsx | 142 +++++++++++++++++--------------- src/style/PathHeaderStyle.ts | 150 +++++++++++++++++----------------- style/images/pin.svg | 11 ++- 4 files changed, 159 insertions(+), 170 deletions(-) diff --git a/LICENSE b/LICENSE index 27637dcc7..0dc89813d 100644 --- a/LICENSE +++ b/LICENSE @@ -27,29 +27,3 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- - -This software makes use of octicons (https://github.com/primer/octicons) licensed under - -MIT License - -Copyright (c) 2019 GitHub Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/components/PathHeader.tsx b/src/components/PathHeader.tsx index abdda586c..aad65bc5c 100644 --- a/src/components/PathHeader.tsx +++ b/src/components/PathHeader.tsx @@ -1,31 +1,46 @@ +import * as React from 'react'; import { Dialog, showDialog, UseSignal } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; -import { FileDialog, FileBrowserModel } from '@jupyterlab/filebrowser'; +import { FileBrowserModel, FileDialog } from '@jupyterlab/filebrowser'; import { DefaultIconReact } from '@jupyterlab/ui-components'; -import * as React from 'react'; import { classes } from 'typestyle'; import { gitPullStyle, gitPushStyle, + noRepoPathStyle, pinIconStyle, repoPathStyle, + repoPinStyle, repoRefreshStyle, repoStyle, - repoPinStyle, - repoPinnedStyle + separatorStyle, + toolBarStyle } from '../style/PathHeaderStyle'; +import { IGitExtension } from '../tokens'; import { GitCredentialsForm } from '../widgets/CredentialsBox'; import { GitPullPushDialog, Operation } from '../widgets/gitPushPull'; -import { IGitExtension } from '../tokens'; +/** + * Properties of the PathHeader React component + */ export interface IPathHeaderProps { + /** + * Git extension model + */ model: IGitExtension; + /** + * File browser model + */ fileBrowserModel: FileBrowserModel; + /** + * Refresh UI callback + */ refresh: () => Promise; } /** * Displays the error dialog when the Git Push/Pull operation fails. + * * @param title the title of the error dialog * @param body the message to be shown in the body of the modal. */ @@ -86,7 +101,8 @@ async function selectGitRepository( model.pathRepository = folder.path; } else if (fileModel.path !== folder.path) { // Change current filebrowser path - fileModel.cd('/' + folder.path); + // => will be propagated to path repository + fileModel.cd(`/${folder.path}`); } } } @@ -107,14 +123,9 @@ export const PathHeader: React.FunctionComponent = ( }); }); - const pinStyles = [repoPinStyle, 'jp-Icon-16']; - if (pin) { - pinStyles.push(repoPinnedStyle); - } - return ( -
-
+ +
-
- - {(_, change) => { - const pathStyles = [repoPathStyle]; - let pinTitle = 'the repository path'; - if (props.model.repositoryPinned) { - pinTitle = 'Unpin ' + pinTitle; - } else { - pinTitle = 'Pin ' + pinTitle; - } + + {(_, change) => { + const pathStyles = [repoPathStyle]; + if (!change.newValue) { + pathStyles.push(noRepoPathStyle); + } - return ( - - - { - selectGitRepository(props.model, props.fileBrowserModel); + let pinTitle = 'the repository path'; + if (pin) { + pinTitle = 'Unpin ' + pinTitle; + } else { + pinTitle = 'Pin ' + pinTitle; + } + + return ( +
+
-
+ /> + + + { + selectGitRepository(props.model, props.fileBrowserModel); + }} + > + {change.newValue + ? PathExt.basename(change.newValue) + : 'Click to select a Git repository.'} + +
+ ); + }} + +
+ ); }; diff --git a/src/style/PathHeaderStyle.ts b/src/style/PathHeaderStyle.ts index 34ae11858..7c9e65b56 100644 --- a/src/style/PathHeaderStyle.ts +++ b/src/style/PathHeaderStyle.ts @@ -1,15 +1,8 @@ import { style } from 'typestyle'; -export const pinIconStyle = style({ - position: 'absolute', - cursor: 'pointer', - top: 0, - left: 0, - right: 0, - bottom: 0 -}); +// Toolbar styles -export const repoStyle = style({ +export const toolBarStyle = style({ display: 'flex', flexDirection: 'row', backgroundColor: 'var(--jp-layout-color1)', @@ -17,74 +10,6 @@ export const repoStyle = style({ minHeight: '35px' }); -export const repoPinStyle = style({ - background: 'var(--jp-layout-color1)', - position: 'relative', - display: 'inline-block', - width: '24px', - height: '24px', - margin: 'auto 5px auto 5px', - - $nest: { - input: { - opacity: 0, - height: 0, - width: 0, - - $nest: { - '&:checked': { - opacity: 10 - } - } - }, - - 'input:checked + svg': { - fill: 'var(--jp-brand-color1)' - }, - - '&:hover': { - backgroundColor: 'var(--jp-layout-color2)' - } - } - // // width: 'var(--jp-private-running-button-width)', - - // border: 'none', - // boxSizing: 'border-box', - // outline: 'none', - // // padding: '0px 6px', - - // $nest: { - // '&:active': { - // backgroundColor: 'var(--jp-layout-color3)' - // } - // } -}); - -export const repoPinnedStyle = style({ - $nest: { - '.jp-icon3': { - fill: 'var(--jp-brand-color1)' - } - } -}); - -export const repoPathStyle = style({ - fontSize: 'var(--jp-ui-font-size1)', - marginRight: '4px', - paddingLeft: '4px', - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - verticalAlign: 'middle', - lineHeight: '33px', - - $nest: { - '&:hover': { - backgroundColor: 'var(--jp-layout-color2)' - } - } -}); - export const repoRefreshStyle = style({ width: 'var(--jp-private-running-button-width)', background: 'var(--jp-layout-color1)', @@ -156,3 +81,74 @@ export const gitPullStyle = style({ } } }); + +// Path styles + +export const repoStyle = style({ + display: 'flex', + flexDirection: 'row', + margin: '4px 12px' +}); + +export const pinIconStyle = style({ + position: 'absolute', + cursor: 'pointer', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: '4px' +}); + +export const repoPinStyle = style({ + background: 'var(--jp-layout-color1)', + position: 'relative', + display: 'inline-block', + width: '24px', + height: '24px', + flex: '0 0 auto', + + $nest: { + input: { + opacity: 0, + height: 0, + width: 0 + }, + + 'input:checked + span': { + transform: 'rotate(-45deg)' + }, + + '&:hover': { + backgroundColor: 'var(--jp-layout-color2)' + } + } +}); + +export const repoPathStyle = style({ + flex: '1 1 auto', + fontSize: 'var(--jp-ui-font-size1)', + padding: '0px 4px 0px 4px', + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + verticalAlign: 'middle', + lineHeight: '24px', + + $nest: { + '&:hover': { + backgroundColor: 'var(--jp-layout-color2)' + } + } +}); + +export const noRepoPathStyle = style({ + color: 'var(--jp-ui-font-color2)' +}); + +// Separator line style + +export const separatorStyle = style({ + flex: '0 0 auto', + borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)' +}); diff --git a/style/images/pin.svg b/style/images/pin.svg index 394c533e8..ace0ad687 100644 --- a/style/images/pin.svg +++ b/style/images/pin.svg @@ -1,4 +1,9 @@ - - - + + From 5bb386d7c22f49c8c9685fe5a3350a9bae9ba04a Mon Sep 17 00:00:00 2001 From: Frederic Collonval Date: Fri, 3 Jan 2020 11:24:37 +0100 Subject: [PATCH 4/6] Correct state saving --- src/model.ts | 47 +++++++++++++++++++++++++++-------------------- src/tokens.ts | 4 ++-- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/model.ts b/src/model.ts index ab5d607a7..43bb9988a 100644 --- a/src/model.ts +++ b/src/model.ts @@ -36,18 +36,19 @@ interface IGitState { export class GitExtension implements IGitExtension { constructor( app: JupyterFrontEnd = null, - settings?: ISettingRegistry.ISettings, - state?: IStateDB + settings: ISettingRegistry.ISettings = null, + state: IStateDB = null ) { const model = this; this._app = app; + this._stateDB = state; - // Load state extension this._state = { isRepositoryPin: false, pathRepository: null }; + // Load state extension this._restored = app.restored.then(() => { if (state) { return state @@ -111,11 +112,9 @@ export class GitExtension implements IGitExtension { * @param settings - settings registry */ function onSettingsChange(settings: ISettingRegistry.ISettings) { - const freq = poll.frequency; poll.frequency = { - interval: settings.composite.refreshInterval as number, - backoff: freq.backoff, - max: freq.max + ...poll.frequency, + interval: settings.composite.refreshInterval as number }; } } @@ -123,7 +122,7 @@ export class GitExtension implements IGitExtension { /** * The list of branch in the current repo */ - get branches() { + get branches(): Git.IBranch[] { return this._branches; } @@ -134,7 +133,7 @@ export class GitExtension implements IGitExtension { /** * The current branch */ - get currentBranch() { + get currentBranch(): Git.IBranch | null { return this._currentBranch; } @@ -391,7 +390,9 @@ export class GitExtension implements IGitExtension { * @param mark Mark to set */ addMark(fname: string, mark: boolean) { - this._currentMarker.add(fname, mark); + if (this._currentMarker) { + this._currentMarker.add(fname, mark); + } } /** @@ -401,7 +402,11 @@ export class GitExtension implements IGitExtension { * @returns Mark of the file */ getMark(fname: string): boolean { - return this._currentMarker.get(fname); + if (this._currentMarker) { + return this._currentMarker.get(fname); + } else { + return false; + } } /** @@ -410,7 +415,9 @@ export class GitExtension implements IGitExtension { * @param fname Filename */ toggleMark(fname: string) { - this._currentMarker.toggle(fname); + if (this._currentMarker) { + this._currentMarker.toggle(fname); + } } /** @@ -1079,27 +1086,27 @@ export class GitExtension implements IGitExtension { return this._currentMarker; } - private _status: Git.IStatusFileResult[] = []; - private _branches: Git.IBranch[]; - private _currentBranch: Git.IBranch; - private _serverRoot: string; private _app: JupyterFrontEnd | null; + private _branches: Git.IBranch[] = []; + private _currentBranch: Git.IBranch | null = null; + private _currentMarker: BranchMarker = null; private _diffProviders: { [key: string]: Git.IDiffCallback } = {}; + private _headChanged = new Signal(this); private _isDisposed = false; private _markerCache: Markers = new Markers(() => this._markChanged.emit()); - private _currentMarker: BranchMarker = null; - private _readyPromise: Promise = Promise.resolve(); + private _markChanged = new Signal(this); private _pendingReadyPromise = 0; private _poll: Poll; - private _headChanged = new Signal(this); - private _markChanged = new Signal(this); + private _readyPromise: Promise = Promise.resolve(); private _repositoryChanged = new Signal< IGitExtension, IChangedArgs >(this); private _restored: Promise; + private _serverRoot: string; private _state: IGitState; private _stateDB: IStateDB | null = null; + private _status: Git.IStatusFileResult[] = []; private _statusChanged = new Signal( this ); diff --git a/src/tokens.ts b/src/tokens.ts index 1572fe2e3..4f54a4023 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -18,7 +18,7 @@ export interface IGitExtension extends IDisposable { /** * The current branch */ - currentBranch: Git.IBranch; + currentBranch: Git.IBranch | null; /** * A signal emitted when the HEAD of the git repository changes. @@ -124,7 +124,7 @@ export interface IGitExtension extends IDisposable { /** 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 + * TODO: Refactor into separate endpoints for each kind of checkout request * * If a branch name is provided, check it out (with or without creating it) * If a filename is provided, check the file out From 40699d6e6c9163fffa970787fe9bad07e236c6ee Mon Sep 17 00:00:00 2001 From: Frederic Collonval Date: Fri, 3 Jan 2020 11:50:24 +0100 Subject: [PATCH 5/6] Link git clone button to file browser path --- src/widgets/gitClone.tsx | 73 +++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/src/widgets/gitClone.tsx b/src/widgets/gitClone.tsx index 415a3735f..bb7cfc61c 100644 --- a/src/widgets/gitClone.tsx +++ b/src/widgets/gitClone.tsx @@ -20,22 +20,18 @@ export function addCloneButton(model: IGitExtension, filebrowser: FileBrowser) { 'gitClone', ReactWidget.create( - {(_, change: IChangedArgs) => ( - { - await doGitClone(model, filebrowser.model.path); - filebrowser.model.refresh(); - }} - tooltip={'Git Clone'} + {(_, change: IChangedArgs) => ( + )} @@ -151,3 +147,54 @@ class GitCloneForm extends Widget { return encodeURIComponent(this.node.querySelector('input').value); } } + +/** + * Git clone toolbar button properties + */ +interface IGitCloneButtonProps { + /** + * Git extension model + */ + model: IGitExtension; + /** + * File browser object + */ + filebrowser: FileBrowser; + /** + * File browser path change + */ + change: IChangedArgs; +} + +const GitCloneButton: React.FunctionComponent = ( + props: IGitCloneButtonProps +) => { + const [enable, setEnable] = React.useState(false); + + React.useEffect(() => { + model + .showTopLevel(change.newValue) + .then(answer => { + setEnable(answer.code !== 0); + }) + .catch(reason => { + console.error( + `Fail to get the top level path for ${change.newValue}.\n${reason}` + ); + }); + }); + + const { model, filebrowser, change } = props; + + return ( + { + await doGitClone(model, filebrowser.model.path); + filebrowser.model.refresh(); + }} + tooltip={'Git Clone'} + /> + ); +}; From ffc4c9adcdb2ed2cddf458f180e8180a35a68279 Mon Sep 17 00:00:00 2001 From: Frederic Collonval Date: Fri, 3 Jan 2020 12:24:23 +0100 Subject: [PATCH 6/6] Correct Jest tests --- src/model.ts | 27 ++++++++++++----------- tests/GitExtension.spec.tsx | 3 ++- tests/test-components/GitPanel.spec.tsx | 4 +++- tests/test-components/PathHeader.spec.tsx | 1 + 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/model.ts b/src/model.ts index 43bb9988a..839e4a810 100644 --- a/src/model.ts +++ b/src/model.ts @@ -49,14 +49,13 @@ export class GitExtension implements IGitExtension { }; // Load state extension - this._restored = app.restored.then(() => { - if (state) { - return state - .fetch(PLUGIN_ID) - .then(value => { + if (app) { + this._restored = app.restored.then(async () => { + if (state) { + try { + const value = await state.fetch(PLUGIN_ID); if (value) { const stateExtension: IGitState = value as any; - if (stateExtension.isRepositoryPin) { const change: IChangedArgs = { name: 'pathRepository', @@ -67,16 +66,18 @@ export class GitExtension implements IGitExtension { this._repositoryChanged.emit(change); } } - }) - .catch(reason => { + } catch (reason) { console.error( `Fail to fetch the state for ${PLUGIN_ID}.\n${reason}` ); - }); - } else { - return Promise.resolve(); - } - }); + } + } else { + return Promise.resolve(); + } + }); + } else { + this._restored = Promise.resolve(); + } // Load the server root path this._getServerRoot() diff --git a/tests/GitExtension.spec.tsx b/tests/GitExtension.spec.tsx index 6fd5ef01b..bce7bff2b 100644 --- a/tests/GitExtension.spec.tsx +++ b/tests/GitExtension.spec.tsx @@ -74,7 +74,8 @@ describe('IGitExtension', () => { const app = { commands: { hasCommand: jest.fn().mockReturnValue(true) - } + }, + restored: Promise.resolve() }; model = new GitExtension(app as any); }); diff --git a/tests/test-components/GitPanel.spec.tsx b/tests/test-components/GitPanel.spec.tsx index eb243f219..c36ad0fcf 100644 --- a/tests/test-components/GitPanel.spec.tsx +++ b/tests/test-components/GitPanel.spec.tsx @@ -55,6 +55,7 @@ function MockSettings() { describe('GitPanel', () => { describe('#commitStagedFiles()', () => { const props: IGitSessionNodeProps = { + fileBrowserModel: null, model: null, renderMime: null, settings: null @@ -69,7 +70,8 @@ describe('GitPanel', () => { const app = { commands: { hasCommand: jest.fn().mockReturnValue(true) - } + }, + restored: Promise.resolve() }; props.model = new GitModel(app as any); props.model.pathRepository = '/path/to/repo'; diff --git a/tests/test-components/PathHeader.spec.tsx b/tests/test-components/PathHeader.spec.tsx index 550e94c41..93e41d53e 100644 --- a/tests/test-components/PathHeader.spec.tsx +++ b/tests/test-components/PathHeader.spec.tsx @@ -52,6 +52,7 @@ describe('PathHeader', function() { props = { model: model, + fileBrowserModel: null, refresh: async () => {} }; });