diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 6cfee14c3..bf81fa6ef 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -1,22 +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'; /** Interface for GitPanel component state */ export interface IGitSessionNodeState { @@ -40,6 +37,7 @@ export interface IGitSessionNodeProps { model: GitExtension; renderMime: IRenderMimeRegistry; settings: ISettingRegistry.ISettings; + fileBrowserModel: FileBrowserModel; } /** A React component for the git extension's main display */ @@ -199,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; @@ -270,26 +268,13 @@ export class GitPanel extends React.Component< {sub} ); - } else { - main = ( -
-
You aren’t in a git repository.
- -
- ); } return (
{ await this.refreshBranch(); if (this.state.isHistoryVisible) { diff --git a/src/components/PathHeader.tsx b/src/components/PathHeader.tsx index 1451b194e..aad65bc5c 100644 --- a/src/components/PathHeader.tsx +++ b/src/components/PathHeader.tsx @@ -1,117 +1,214 @@ +import * as React from 'react'; import { Dialog, showDialog, UseSignal } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; -import * as React from 'react'; +import { FileBrowserModel, FileDialog } from '@jupyterlab/filebrowser'; +import { DefaultIconReact } from '@jupyterlab/ui-components'; import { classes } from 'typestyle'; import { gitPullStyle, gitPushStyle, + noRepoPathStyle, + pinIconStyle, repoPathStyle, + repoPinStyle, repoRefreshStyle, - repoStyle + repoStyle, + 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; } -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; + } } +} - render() { - return ( -
- - {(_, change) => ( - - {PathExt.basename(change.newValue || '')} - - )} - +/** + * 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 + // => will be propagated to path repository + 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); + }); + }); + + return ( + +
- ); - } - - /** - * 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; + + {(_, change) => { + const pathStyles = [repoPathStyle]; + if (!change.newValue) { + pathStyles.push(noRepoPathStyle); + } - 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' })] - }); + let pinTitle = 'the repository path'; + if (pin) { + 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); + }} + > + {change.newValue + ? 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..839e4a810 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,14 +16,68 @@ 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 = null, + state: IStateDB = null ) { const model = this; this._app = app; + this._stateDB = state; + + this._state = { + isRepositoryPin: false, + pathRepository: null + }; + + // Load state extension + 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', + 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(); + } + }); + } else { + this._restored = Promise.resolve(); + } // Load the server root path this._getServerRoot() @@ -58,11 +113,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 }; } } @@ -70,7 +123,7 @@ export class GitExtension implements IGitExtension { /** * The list of branch in the current repo */ - get branches() { + get branches(): Git.IBranch[] { return this._branches; } @@ -81,7 +134,7 @@ export class GitExtension implements IGitExtension { /** * The current branch */ - get currentBranch() { + get currentBranch(): Git.IBranch | null { return this._currentBranch; } @@ -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; } @@ -305,7 +391,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); + } } /** @@ -315,7 +403,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; + } } /** @@ -324,7 +416,9 @@ export class GitExtension implements IGitExtension { * @param fname Filename */ toggleMark(fname: string) { - this._currentMarker.toggle(fname); + if (this._currentMarker) { + this._currentMarker.toggle(fname); + } } /** @@ -993,25 +1087,27 @@ export class GitExtension implements IGitExtension { return this._currentMarker; } - private _status: Git.IStatusFileResult[] = []; - private _pathRepository: string | null = null; - 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/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 1043ef8de..edc9608bb 100644 --- a/src/style/GitPanelStyle.ts +++ b/src/style/GitPanelStyle.ts @@ -4,16 +4,8 @@ export const panelContainerStyle = style({ display: 'flex', flexDirection: 'column', overflowY: 'auto', - height: '100%' -}); - -export const panelWarningStyle = style({ - textAlign: 'center', - marginTop: '9px' -}); - -export const findRepoButtonStyle = style({ - color: 'white', - backgroundColor: 'var(--jp-brand-color1)', - marginTop: '5px' + height: '100%', + color: 'var(--jp-ui-font-color1)', + background: 'var(--jp-layout-color1)', + fontSize: 'var(--jp-ui-font-size1)' }); 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..7c9e65b56 100644 --- a/src/style/PathHeaderStyle.ts +++ b/src/style/PathHeaderStyle.ts @@ -1,6 +1,8 @@ import { style } from 'typestyle'; -export const repoStyle = style({ +// Toolbar styles + +export const toolBarStyle = style({ display: 'flex', flexDirection: 'row', backgroundColor: 'var(--jp-layout-color1)', @@ -8,17 +10,6 @@ export const repoStyle = style({ minHeight: '35px' }); -export const repoPathStyle = style({ - fontSize: 'var(--jp-ui-font-size1)', - marginRight: '4px', - paddingLeft: '4px', - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - verticalAlign: 'middle', - lineHeight: '33px' -}); - export const repoRefreshStyle = style({ width: 'var(--jp-private-running-button-width)', background: 'var(--jp-layout-color1)', @@ -90,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/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..4f54a4023 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -18,13 +18,25 @@ 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. */ 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. @@ -114,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 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/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'} + /> + ); +}; diff --git a/style/images/pin.svg b/style/images/pin.svg new file mode 100644 index 000000000..ace0ad687 --- /dev/null +++ b/style/images/pin.svg @@ -0,0 +1,9 @@ + + + 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 () => {} }; });