Skip to content

Commit 6b62f1f

Browse files
authored
Add submodules display (#1374)
* Get submodule names tokens/model/handlers * Fix submodule return * Remove null * Add submodules to UI * Temp spot to get submodule list * Update submodule command * Make submodule display conditional * Move where listSubModules() gets called * Add submodules to model in test * Add doc comments * Have GitPanel manage submodule state * Add/update tests * Add header and style * Fix submodule fetching when update hasn't been run * Change casing for submodules handler * Change casing
1 parent dc7271e commit 6b62f1f

File tree

12 files changed

+391
-20
lines changed

12 files changed

+391
-20
lines changed

jupyterlab_git/git.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,26 @@ async def apply_stash(self, path: str, stash_index: Optional[int] = None) -> dic
21832183

21842184
return {"code": code, "message": output.strip()}
21852185

2186+
async def submodule(self, path):
2187+
"""
2188+
Execute git submodule status --recursive
2189+
"""
2190+
2191+
cmd = ["git", "submodule", "status", "--recursive"]
2192+
2193+
code, output, error = await self.__execute(cmd, cwd=path)
2194+
2195+
results = []
2196+
2197+
for line in output.splitlines():
2198+
name = line.strip().split(" ")[1]
2199+
submodule = {
2200+
"name": name,
2201+
}
2202+
results.append(submodule)
2203+
2204+
return {"code": code, "submodules": results, "error": error}
2205+
21862206
@property
21872207
def excluded_paths(self) -> List[str]:
21882208
"""Wildcard-style path patterns that do not support git commands.

jupyterlab_git/handlers.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Module with all the individual handlers, which execute git commands and return the results to the frontend.
33
"""
44

5+
import fnmatch
56
import functools
67
import json
78
import os
@@ -11,9 +12,8 @@
1112
import tornado
1213
from jupyter_server.base.handlers import APIHandler, path_regex
1314
from jupyter_server.services.contents.manager import ContentsManager
14-
from jupyter_server.utils import url2path, url_path_join, ensure_async
15+
from jupyter_server.utils import ensure_async, url2path, url_path_join
1516
from packaging.version import parse
16-
import fnmatch
1717

1818
try:
1919
import hybridcontents
@@ -915,7 +915,7 @@ async def post(self, path: str = ""):
915915

916916
class GitNewTagHandler(GitHandler):
917917
"""
918-
Hadler for 'git tag <tag_name> <commit_id>. Create new tag pointing to a specific commit.
918+
Handler for 'git tag <tag_name> <commit_id>. Create new tag pointing to a specific commit.
919919
"""
920920

921921
@tornado.web.authenticated
@@ -1069,6 +1069,24 @@ async def post(self, path: str = ""):
10691069
self.finish(json.dumps(response))
10701070

10711071

1072+
class GitSubmodulesHandler(GitHandler):
1073+
"""
1074+
Handler for 'git submodule status --recursive.
1075+
Get a list of submodules in the repo.
1076+
"""
1077+
1078+
@tornado.web.authenticated
1079+
async def get(self, path: str = ""):
1080+
"""
1081+
GET request handler, fetches all submodules in current repository.
1082+
"""
1083+
result = await self.git.submodule(self.url2localpath(path))
1084+
1085+
if result["code"] != 0:
1086+
self.set_status(500)
1087+
self.finish(json.dumps(result))
1088+
1089+
10721090
def setup_handlers(web_app):
10731091
"""
10741092
Setups all of the git command handlers.
@@ -1113,6 +1131,7 @@ def setup_handlers(web_app):
11131131
("/stash", GitStashHandler),
11141132
("/stash_pop", GitStashPopHandler),
11151133
("/stash_apply", GitStashApplyHandler),
1134+
("/submodules", GitSubmodulesHandler),
11161135
]
11171136

11181137
handlers = [

src/__tests__/test-components/GitPanel.spec.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as apputils from '@jupyterlab/apputils';
22
import { nullTranslator } from '@jupyterlab/translation';
3+
import { CommandRegistry } from '@lumino/commands';
34
import { JSONObject } from '@lumino/coreutils';
45
import '@testing-library/jest-dom';
56
import { RenderResult, render, screen, waitFor } from '@testing-library/react';
@@ -10,12 +11,11 @@ import { GitPanel, IGitPanelProps } from '../../components/GitPanel';
1011
import * as git from '../../git';
1112
import { GitExtension as GitModel } from '../../model';
1213
import {
13-
defaultMockedResponses,
1414
DEFAULT_REPOSITORY_PATH,
1515
IMockedResponse,
16+
defaultMockedResponses,
1617
mockedRequestAPI
1718
} from '../utils';
18-
import { CommandRegistry } from '@lumino/commands';
1919

2020
jest.mock('../../git');
2121
jest.mock('@jupyterlab/apputils');
@@ -372,6 +372,7 @@ describe('GitPanel', () => {
372372
beforeEach(() => {
373373
props.model = {
374374
branches: [],
375+
submodules: [],
375376
status: {},
376377
stashChanged: {
377378
connect: jest.fn()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { nullTranslator } from '@jupyterlab/translation';
2+
import '@testing-library/jest-dom';
3+
import { render, screen } from '@testing-library/react';
4+
import 'jest';
5+
import * as React from 'react';
6+
import {
7+
ISubmoduleMenuProps,
8+
SubmoduleMenu
9+
} from '../../components/SubmoduleMenu';
10+
import { GitExtension } from '../../model';
11+
import { IGitExtension } from '../../tokens';
12+
import { DEFAULT_REPOSITORY_PATH } from '../utils';
13+
14+
jest.mock('../../git');
15+
jest.mock('@jupyterlab/apputils');
16+
17+
const SUBMODULES = [
18+
{
19+
name: 'cli/bench'
20+
},
21+
{
22+
name: 'test/util'
23+
}
24+
];
25+
26+
async function createModel() {
27+
const model = new GitExtension();
28+
model.pathRepository = DEFAULT_REPOSITORY_PATH;
29+
30+
await model.ready;
31+
return model;
32+
}
33+
34+
describe('Submodule Menu', () => {
35+
let model: GitExtension;
36+
const trans = nullTranslator.load('jupyterlab_git');
37+
38+
beforeEach(async () => {
39+
jest.restoreAllMocks();
40+
41+
model = await createModel();
42+
});
43+
44+
function createProps(
45+
props?: Partial<ISubmoduleMenuProps>
46+
): ISubmoduleMenuProps {
47+
return {
48+
model: model as IGitExtension,
49+
trans: trans,
50+
submodules: SUBMODULES,
51+
...props
52+
};
53+
}
54+
55+
describe('render', () => {
56+
it('should display a list of submodules', () => {
57+
render(<SubmoduleMenu {...createProps()} />);
58+
59+
const submodules = SUBMODULES;
60+
expect(screen.getAllByRole('listitem').length).toEqual(submodules.length);
61+
62+
// Should contain the submodule names...
63+
for (let i = 0; i < submodules.length; i++) {
64+
expect(
65+
screen.getByText(submodules[i].name, { exact: true })
66+
).toBeDefined();
67+
}
68+
});
69+
});
70+
});

src/__tests__/test-components/Toolbar.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe('Toolbar', () => {
7272
execute: jest.fn()
7373
} as any,
7474
trans: trans,
75+
submodules: model.submodules,
7576
...props
7677
};
7778
}

src/components/GitPanel.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ export interface IGitPanelState {
155155
*
156156
*/
157157
stash: Git.IStash[];
158+
159+
/**
160+
* List of submodules.
161+
*/
162+
submodules: Git.ISubmodule[];
158163
}
159164

160165
/**
@@ -175,7 +180,8 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
175180
pathRepository,
176181
hasDirtyFiles: hasDirtyStagedFiles,
177182
stash,
178-
tagsList
183+
tagsList,
184+
submodules: submodules
179185
} = props.model;
180186

181187
this.state = {
@@ -195,7 +201,8 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
195201
referenceCommit: null,
196202
challengerCommit: null,
197203
stash: stash,
198-
tagsList: tagsList
204+
tagsList: tagsList,
205+
submodules: submodules
199206
};
200207
}
201208

@@ -246,6 +253,9 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
246253
model.remoteChanged.connect((_, args) => {
247254
this.warningDialog(args!);
248255
}, this);
256+
model.repositoryChanged.connect(async () => {
257+
await this.refreshSubmodules();
258+
}, this);
249259

250260
settings.changed.connect(this.refreshView, this);
251261

@@ -300,6 +310,13 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
300310
}
301311
};
302312

313+
refreshSubmodules = async (): Promise<void> => {
314+
await this.props.model.listSubmodules();
315+
this.setState({
316+
submodules: this.props.model.submodules
317+
});
318+
};
319+
303320
/**
304321
* Refresh widget, update all content
305322
*/
@@ -410,6 +427,7 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
410427
nCommitsBehind={this.state.nCommitsBehind}
411428
repository={this.state.repository || ''}
412429
trans={this.props.trans}
430+
submodules={this.state.submodules}
413431
/>
414432
);
415433
}

src/components/SubmoduleMenu.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { TranslationBundle } from '@jupyterlab/translation';
2+
import ListItem from '@mui/material/ListItem';
3+
import * as React from 'react';
4+
import { FixedSizeList, ListChildComponentProps } from 'react-window';
5+
import {
6+
listItemClass,
7+
listItemIconClass,
8+
nameClass,
9+
wrapperClass
10+
} from '../style/BranchMenu';
11+
import { submoduleHeaderStyle } from '../style/SubmoduleMenuStyle';
12+
import { desktopIcon } from '../style/icons';
13+
import { Git, IGitExtension } from '../tokens';
14+
15+
const ITEM_HEIGHT = 24.8; // HTML element height for a single item
16+
const MIN_HEIGHT = 150; // Minimal HTML element height for the list
17+
const MAX_HEIGHT = 400; // Maximal HTML element height for the list
18+
19+
/**
20+
* Interface describing component properties.
21+
*/
22+
export interface ISubmoduleMenuProps {
23+
/**
24+
* Git extension data model.
25+
*/
26+
model: IGitExtension;
27+
28+
/**
29+
* The list of submodules in the repo
30+
*/
31+
submodules: Git.ISubmodule[];
32+
33+
/**
34+
* The application language translator.
35+
*/
36+
trans: TranslationBundle;
37+
}
38+
39+
/**
40+
* Interface describing component state.
41+
*/
42+
export interface ISubmoduleMenuState {}
43+
44+
/**
45+
* React component for rendering a submodule menu.
46+
*/
47+
export class SubmoduleMenu extends React.Component<
48+
ISubmoduleMenuProps,
49+
ISubmoduleMenuState
50+
> {
51+
/**
52+
* Returns a React component for rendering a submodule menu.
53+
*
54+
* @param props - component properties
55+
* @returns React component
56+
*/
57+
constructor(props: ISubmoduleMenuProps) {
58+
super(props);
59+
}
60+
61+
/**
62+
* Renders the component.
63+
*
64+
* @returns React element
65+
*/
66+
render(): React.ReactElement {
67+
return <div className={wrapperClass}>{this._renderSubmoduleList()}</div>;
68+
}
69+
70+
/**
71+
* Renders list of submodules.
72+
*
73+
* @returns React element
74+
*/
75+
private _renderSubmoduleList(): React.ReactElement {
76+
const submodules = this.props.submodules;
77+
78+
return (
79+
<>
80+
<div className={submoduleHeaderStyle}>Submodules</div>
81+
<FixedSizeList
82+
height={Math.min(
83+
Math.max(MIN_HEIGHT, submodules.length * ITEM_HEIGHT),
84+
MAX_HEIGHT
85+
)}
86+
itemCount={submodules.length}
87+
itemData={submodules}
88+
itemKey={(index, data) => data[index].name}
89+
itemSize={ITEM_HEIGHT}
90+
style={{
91+
overflowX: 'hidden',
92+
paddingTop: 0,
93+
paddingBottom: 0
94+
}}
95+
width={'auto'}
96+
>
97+
{this._renderItem}
98+
</FixedSizeList>
99+
</>
100+
);
101+
}
102+
103+
/**
104+
* Renders a menu item.
105+
*
106+
* @param props Row properties
107+
* @returns React element
108+
*/
109+
private _renderItem = (props: ListChildComponentProps): JSX.Element => {
110+
const { data, index, style } = props;
111+
const submodule = data[index] as Git.ISubmodule;
112+
113+
return (
114+
<ListItem
115+
title={this.props.trans.__('Submodule: %1', submodule.name)}
116+
className={listItemClass}
117+
role="listitem"
118+
style={style}
119+
>
120+
<desktopIcon.react className={listItemIconClass} tag="span" />
121+
<span className={nameClass}>{submodule.name}</span>
122+
</ListItem>
123+
);
124+
};
125+
}

0 commit comments

Comments
 (0)