diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 39b1e6396..e3b3bac25 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -2183,6 +2183,26 @@ async def apply_stash(self, path: str, stash_index: Optional[int] = None) -> dic return {"code": code, "message": output.strip()} + async def submodule(self, path): + """ + Execute git submodule status --recursive + """ + + cmd = ["git", "submodule", "status", "--recursive"] + + code, output, error = await self.__execute(cmd, cwd=path) + + results = [] + + for line in output.splitlines(): + name = line.strip().split(" ")[1] + submodule = { + "name": name, + } + results.append(submodule) + + return {"code": code, "submodules": results, "error": error} + @property def excluded_paths(self) -> List[str]: """Wildcard-style path patterns that do not support git commands. diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 5bdc4abb0..34d09c506 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -2,6 +2,7 @@ Module with all the individual handlers, which execute git commands and return the results to the frontend. """ +import fnmatch import functools import json import os @@ -11,9 +12,8 @@ import tornado from jupyter_server.base.handlers import APIHandler, path_regex from jupyter_server.services.contents.manager import ContentsManager -from jupyter_server.utils import url2path, url_path_join, ensure_async +from jupyter_server.utils import ensure_async, url2path, url_path_join from packaging.version import parse -import fnmatch try: import hybridcontents @@ -915,7 +915,7 @@ async def post(self, path: str = ""): class GitNewTagHandler(GitHandler): """ - Hadler for 'git tag . Create new tag pointing to a specific commit. + Handler for 'git tag . Create new tag pointing to a specific commit. """ @tornado.web.authenticated @@ -1069,6 +1069,24 @@ async def post(self, path: str = ""): self.finish(json.dumps(response)) +class GitSubmodulesHandler(GitHandler): + """ + Handler for 'git submodule status --recursive. + Get a list of submodules in the repo. + """ + + @tornado.web.authenticated + async def get(self, path: str = ""): + """ + GET request handler, fetches all submodules in current repository. + """ + result = await self.git.submodule(self.url2localpath(path)) + + if result["code"] != 0: + self.set_status(500) + self.finish(json.dumps(result)) + + def setup_handlers(web_app): """ Setups all of the git command handlers. @@ -1113,6 +1131,7 @@ def setup_handlers(web_app): ("/stash", GitStashHandler), ("/stash_pop", GitStashPopHandler), ("/stash_apply", GitStashApplyHandler), + ("/submodules", GitSubmodulesHandler), ] handlers = [ diff --git a/src/__tests__/test-components/GitPanel.spec.tsx b/src/__tests__/test-components/GitPanel.spec.tsx index 43a009a11..defda4230 100644 --- a/src/__tests__/test-components/GitPanel.spec.tsx +++ b/src/__tests__/test-components/GitPanel.spec.tsx @@ -1,5 +1,6 @@ import * as apputils from '@jupyterlab/apputils'; import { nullTranslator } from '@jupyterlab/translation'; +import { CommandRegistry } from '@lumino/commands'; import { JSONObject } from '@lumino/coreutils'; import '@testing-library/jest-dom'; import { RenderResult, render, screen, waitFor } from '@testing-library/react'; @@ -10,12 +11,11 @@ import { GitPanel, IGitPanelProps } from '../../components/GitPanel'; import * as git from '../../git'; import { GitExtension as GitModel } from '../../model'; import { - defaultMockedResponses, DEFAULT_REPOSITORY_PATH, IMockedResponse, + defaultMockedResponses, mockedRequestAPI } from '../utils'; -import { CommandRegistry } from '@lumino/commands'; jest.mock('../../git'); jest.mock('@jupyterlab/apputils'); @@ -372,6 +372,7 @@ describe('GitPanel', () => { beforeEach(() => { props.model = { branches: [], + submodules: [], status: {}, stashChanged: { connect: jest.fn() diff --git a/src/__tests__/test-components/SubModuleMenu.spec.tsx b/src/__tests__/test-components/SubModuleMenu.spec.tsx new file mode 100644 index 000000000..988d59014 --- /dev/null +++ b/src/__tests__/test-components/SubModuleMenu.spec.tsx @@ -0,0 +1,70 @@ +import { nullTranslator } from '@jupyterlab/translation'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import 'jest'; +import * as React from 'react'; +import { + ISubmoduleMenuProps, + SubmoduleMenu +} from '../../components/SubmoduleMenu'; +import { GitExtension } from '../../model'; +import { IGitExtension } from '../../tokens'; +import { DEFAULT_REPOSITORY_PATH } from '../utils'; + +jest.mock('../../git'); +jest.mock('@jupyterlab/apputils'); + +const SUBMODULES = [ + { + name: 'cli/bench' + }, + { + name: 'test/util' + } +]; + +async function createModel() { + const model = new GitExtension(); + model.pathRepository = DEFAULT_REPOSITORY_PATH; + + await model.ready; + return model; +} + +describe('Submodule Menu', () => { + let model: GitExtension; + const trans = nullTranslator.load('jupyterlab_git'); + + beforeEach(async () => { + jest.restoreAllMocks(); + + model = await createModel(); + }); + + function createProps( + props?: Partial + ): ISubmoduleMenuProps { + return { + model: model as IGitExtension, + trans: trans, + submodules: SUBMODULES, + ...props + }; + } + + describe('render', () => { + it('should display a list of submodules', () => { + render(); + + const submodules = SUBMODULES; + expect(screen.getAllByRole('listitem').length).toEqual(submodules.length); + + // Should contain the submodule names... + for (let i = 0; i < submodules.length; i++) { + expect( + screen.getByText(submodules[i].name, { exact: true }) + ).toBeDefined(); + } + }); + }); +}); diff --git a/src/__tests__/test-components/Toolbar.spec.tsx b/src/__tests__/test-components/Toolbar.spec.tsx index 7ab92af3c..884a77548 100644 --- a/src/__tests__/test-components/Toolbar.spec.tsx +++ b/src/__tests__/test-components/Toolbar.spec.tsx @@ -72,6 +72,7 @@ describe('Toolbar', () => { execute: jest.fn() } as any, trans: trans, + submodules: model.submodules, ...props }; } diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 4fd1dd03d..0d7c3099b 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -155,6 +155,11 @@ export interface IGitPanelState { * */ stash: Git.IStash[]; + + /** + * List of submodules. + */ + submodules: Git.ISubmodule[]; } /** @@ -175,7 +180,8 @@ export class GitPanel extends React.Component { pathRepository, hasDirtyFiles: hasDirtyStagedFiles, stash, - tagsList + tagsList, + submodules: submodules } = props.model; this.state = { @@ -195,7 +201,8 @@ export class GitPanel extends React.Component { referenceCommit: null, challengerCommit: null, stash: stash, - tagsList: tagsList + tagsList: tagsList, + submodules: submodules }; } @@ -246,6 +253,9 @@ export class GitPanel extends React.Component { model.remoteChanged.connect((_, args) => { this.warningDialog(args!); }, this); + model.repositoryChanged.connect(async () => { + await this.refreshSubmodules(); + }, this); settings.changed.connect(this.refreshView, this); @@ -300,6 +310,13 @@ export class GitPanel extends React.Component { } }; + refreshSubmodules = async (): Promise => { + await this.props.model.listSubmodules(); + this.setState({ + submodules: this.props.model.submodules + }); + }; + /** * Refresh widget, update all content */ @@ -410,6 +427,7 @@ export class GitPanel extends React.Component { nCommitsBehind={this.state.nCommitsBehind} repository={this.state.repository || ''} trans={this.props.trans} + submodules={this.state.submodules} /> ); } diff --git a/src/components/SubmoduleMenu.tsx b/src/components/SubmoduleMenu.tsx new file mode 100644 index 000000000..1a1baf906 --- /dev/null +++ b/src/components/SubmoduleMenu.tsx @@ -0,0 +1,125 @@ +import { TranslationBundle } from '@jupyterlab/translation'; +import ListItem from '@mui/material/ListItem'; +import * as React from 'react'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; +import { + listItemClass, + listItemIconClass, + nameClass, + wrapperClass +} from '../style/BranchMenu'; +import { submoduleHeaderStyle } from '../style/SubmoduleMenuStyle'; +import { desktopIcon } from '../style/icons'; +import { Git, IGitExtension } from '../tokens'; + +const ITEM_HEIGHT = 24.8; // HTML element height for a single item +const MIN_HEIGHT = 150; // Minimal HTML element height for the list +const MAX_HEIGHT = 400; // Maximal HTML element height for the list + +/** + * Interface describing component properties. + */ +export interface ISubmoduleMenuProps { + /** + * Git extension data model. + */ + model: IGitExtension; + + /** + * The list of submodules in the repo + */ + submodules: Git.ISubmodule[]; + + /** + * The application language translator. + */ + trans: TranslationBundle; +} + +/** + * Interface describing component state. + */ +export interface ISubmoduleMenuState {} + +/** + * React component for rendering a submodule menu. + */ +export class SubmoduleMenu extends React.Component< + ISubmoduleMenuProps, + ISubmoduleMenuState +> { + /** + * Returns a React component for rendering a submodule menu. + * + * @param props - component properties + * @returns React component + */ + constructor(props: ISubmoduleMenuProps) { + super(props); + } + + /** + * Renders the component. + * + * @returns React element + */ + render(): React.ReactElement { + return
{this._renderSubmoduleList()}
; + } + + /** + * Renders list of submodules. + * + * @returns React element + */ + private _renderSubmoduleList(): React.ReactElement { + const submodules = this.props.submodules; + + return ( + <> +
Submodules
+ data[index].name} + itemSize={ITEM_HEIGHT} + style={{ + overflowX: 'hidden', + paddingTop: 0, + paddingBottom: 0 + }} + width={'auto'} + > + {this._renderItem} + + + ); + } + + /** + * Renders a menu item. + * + * @param props Row properties + * @returns React element + */ + private _renderItem = (props: ListChildComponentProps): JSX.Element => { + const { data, index, style } = props; + const submodule = data[index] as Git.ISubmodule; + + return ( + + + {submodule.name} + + ); + }; +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 947eb2903..6febd8d48 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -35,6 +35,7 @@ import { branchIcon, desktopIcon, pullIcon, pushIcon } from '../style/icons'; import { CommandIDs, Git, IGitExtension } from '../tokens'; import { ActionButton } from './ActionButton'; import { BranchMenu } from './BranchMenu'; +import { SubmoduleMenu } from './SubmoduleMenu'; import { TagMenu } from './TagMenu'; /** @@ -95,6 +96,11 @@ export interface IToolbarProps { * The application language translator. */ trans: TranslationBundle; + + /** + * Current list of submodules + */ + submodules: Git.ISubmodule[]; } /** @@ -120,6 +126,11 @@ export interface IToolbarState { * Boolean indicating whether a remote exists. */ hasRemote: boolean; + + /** + * Boolean indicating whether a repo menu is shown. + */ + repoMenu: boolean; } /** @@ -138,7 +149,8 @@ export class Toolbar extends React.Component { branchMenu: false, tab: 0, refreshInProgress: false, - hasRemote: false + hasRemote: false, + repoMenu: false }; } @@ -255,6 +267,7 @@ export class Toolbar extends React.Component { * @returns React element */ private _renderRepoMenu(): React.ReactElement { + const hasSubmodules = !(this.props.submodules.length === 0); const repositoryName = PathExt.basename( this.props.repository || PageConfig.getOption('serverRoot') @@ -262,12 +275,16 @@ export class Toolbar extends React.Component { return (
+ {hasSubmodules && + (this.state.repoMenu ? ( + + ) : ( + + ))} + {this.state.repoMenu ? this._renderSubmodules() : null} ); } @@ -411,6 +441,16 @@ export class Toolbar extends React.Component { ); } + private _renderSubmodules(): JSX.Element { + return ( + + ); + } + /** * Callback invoked upon clicking a button to pull the latest changes. * @@ -443,6 +483,13 @@ export class Toolbar extends React.Component { }); }; + private _onRepoClick = (): void => { + // Toggle the submodule menu: + this.setState({ + repoMenu: !this.state.repoMenu + }); + }; + /** * Callback invoked upon clicking a button to refresh the model. * diff --git a/src/model.ts b/src/model.ts index 4e135cd34..aa4204066 100644 --- a/src/model.ts +++ b/src/model.ts @@ -88,6 +88,13 @@ export class GitExtension implements IGitExtension { return this._branches; } + /** + * Submodule list for the current repository. + */ + get submodules(): Git.ISubmodule[] { + return this._submodules; + } + /** * Tags list for the current repository. */ @@ -1976,6 +1983,35 @@ export class GitExtension implements IGitExtension { await this.commit(message); } + /** + * Get the submodules in the current repository. + + * @returns A promise that resolves upon getting submodule names + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async listSubmodules(): Promise { + try { + const path = await this._getPathRepository(); + const data = await this._taskHandler.execute( + 'git:submodules:list', + async () => { + return await requestAPI( + URLExt.join(path, 'submodules'), + 'GET' + ); + } + ); + + const newSubmodules = data.submodules; + this._submodules = newSubmodules; + } catch (error) { + console.error('Failed to retrieve submodules'); + } + } + /** * Make request for a list of all git branches in the repository * Retrieve a list of repository branches. @@ -2258,6 +2294,7 @@ export class GitExtension implements IGitExtension { private _hasDirtyFiles = false; private _credentialsRequired = false; private _lastAuthor: Git.IIdentity | null = null; + private _submodules: Git.ISubmodule[] = []; // Configurable private _statusForDirtyState: Git.Status[] = ['staged', 'partially-staged']; diff --git a/src/style/SubmoduleMenuStyle.ts b/src/style/SubmoduleMenuStyle.ts new file mode 100644 index 000000000..d7da3f1c7 --- /dev/null +++ b/src/style/SubmoduleMenuStyle.ts @@ -0,0 +1,12 @@ +import { style } from 'typestyle'; + +export const submoduleHeaderStyle = style({ + padding: '4px 4px 1px', + margin: '0 6px', + fontWeight: 600, + letterSpacing: '1px', + fontSize: '12px', + overflowY: 'hidden', + borderBottom: '3px solid var(--jp-brand-color1)', + height: '16px' +}); diff --git a/src/tokens.ts b/src/tokens.ts index f9be80a10..e94c79700 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -29,6 +29,11 @@ export interface IGitExtension extends IDisposable { */ currentBranch: Git.IBranch | null; + /** + * The list of submodules in the current repo + */ + submodules: Git.ISubmodule[]; + /** * A signal emitted when the branches of the Git repository changes. */ @@ -976,6 +981,22 @@ export namespace Git { current_branch?: IBranch; } + /** + * Submodule description interface + */ + export interface ISubmodule { + name: string; + } + + /** + * Interface for submodule request result, + * has the name of the submodules in the current repo + */ + export interface ISubmoduleResult { + code: number; + submodules: ISubmodule[]; + } + /** * Data interface of diffcontent request */ diff --git a/src/widgets/GitCloneForm.ts b/src/widgets/GitCloneForm.ts index c42c47990..7d513ee94 100644 --- a/src/widgets/GitCloneForm.ts +++ b/src/widgets/GitCloneForm.ts @@ -43,17 +43,17 @@ export class GitCloneForm extends Widget { const inputLink = document.createElement('input'); const linkText = document.createElement('span'); const checkboxWrapper = document.createElement('div'); - const subModulesLabel = document.createElement('label'); - const subModules = document.createElement('input'); + const submodulesLabel = document.createElement('label'); + const submodules = document.createElement('input'); const downloadLabel = document.createElement('label'); const download = document.createElement('input'); node.className = 'jp-CredentialsBox'; inputWrapper.className = 'jp-RedirectForm'; checkboxWrapper.className = 'jp-CredentialsBox-wrapper'; - subModulesLabel.className = 'jp-CredentialsBox-label-checkbox'; + submodulesLabel.className = 'jp-CredentialsBox-label-checkbox'; downloadLabel.className = 'jp-CredentialsBox-label-checkbox'; - subModules.id = 'submodules'; + submodules.id = 'submodules'; download.id = 'download'; inputLink.id = 'input-link'; @@ -62,12 +62,12 @@ export class GitCloneForm extends Widget { ); inputLink.placeholder = 'https://host.com/org/repo.git'; - subModulesLabel.textContent = trans.__('Include submodules'); - subModulesLabel.title = trans.__( + submodulesLabel.textContent = trans.__('Include submodules'); + submodulesLabel.title = trans.__( 'If checked, the remote submodules in the repository will be cloned recursively' ); - subModules.setAttribute('type', 'checkbox'); - subModules.setAttribute('checked', 'checked'); + submodules.setAttribute('type', 'checkbox'); + submodules.setAttribute('checked', 'checked'); downloadLabel.textContent = trans.__('Download the repository'); downloadLabel.title = trans.__( @@ -80,8 +80,8 @@ export class GitCloneForm extends Widget { inputWrapper.append(inputLinkLabel); - subModulesLabel.prepend(subModules); - checkboxWrapper.appendChild(subModulesLabel); + submodulesLabel.prepend(submodules); + checkboxWrapper.appendChild(submodulesLabel); downloadLabel.prepend(download); checkboxWrapper.appendChild(downloadLabel);