Skip to content

Commit e1de278

Browse files
authored
Virtualized files list (#767)
* Virtualized files list * Windowing on history files list * Window on branches list * Handle filter branch list * Correct unit test
1 parent 706fb7a commit e1de278

16 files changed

+642
-425
lines changed

jupyterlab_git/handlers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ async def post(self):
175175
selected_hash = data["selected_hash"]
176176
current_path = data["current_path"]
177177
result = await self.git.detailed_log(selected_hash, current_path)
178+
179+
if result["code"] != 0:
180+
self.set_status(500)
178181
self.finish(json.dumps(result))
179182

180183

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@
7373
"react": "~16.9.0",
7474
"react-dom": "~16.9.0",
7575
"react-textarea-autosize": "^7.1.2",
76+
"react-virtualized-auto-sizer": "^1.0.2",
77+
"react-window": "^1.8.5",
7678
"typestyle": "^2.0.1"
7779
},
7880
"devDependencies": {
@@ -86,6 +88,8 @@
8688
"@types/react": "~16.8.13",
8789
"@types/react-dom": "~16.0.5",
8890
"@types/react-textarea-autosize": "^4.3.5",
91+
"@types/react-virtualized-auto-sizer": "^1.0.0",
92+
"@types/react-window": "^1.8.2",
8993
"@typescript-eslint/eslint-plugin": "^2.25.0",
9094
"@typescript-eslint/parser": "^2.25.0",
9195
"all-contributors-cli": "^6.14.0",

src/components/BranchMenu.tsx

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import List from '@material-ui/core/List';
33
import ListItem from '@material-ui/core/ListItem';
44
import ClearIcon from '@material-ui/icons/Clear';
55
import * as React from 'react';
6+
import { FixedSizeList, ListChildComponentProps } from 'react-window';
67
import { classes } from 'typestyle';
78
import {
89
activeListItemClass,
@@ -12,7 +13,6 @@ import {
1213
filterWrapperClass,
1314
listItemClass,
1415
listItemIconClass,
15-
listWrapperClass,
1616
newBranchButtonClass,
1717
wrapperClass
1818
} from '../style/BranchMenu';
@@ -24,6 +24,9 @@ import { SuspendModal } from './SuspendModal';
2424

2525
const CHANGES_ERR_MSG =
2626
'The current branch contains files with uncommitted changes. Please commit or discard these changes before switching to or creating another branch.';
27+
const ITEM_HEIGHT = 24.8; // HTML element height for a single branch
28+
const MIN_HEIGHT = 150; // Minimal HTML element height for the branches list
29+
const MAX_HEIGHT = 400; // Maximal HTML element height for the branches list
2730

2831
/**
2932
* Callback invoked upon encountering an error when switching branches.
@@ -237,37 +240,38 @@ export class BranchMenu extends React.Component<
237240
* @returns React element
238241
*/
239242
private _renderBranchList(): React.ReactElement {
243+
// Perform a "simple" filter... (TODO: consider implementing fuzzy filtering)
244+
const filter = this.state.filter;
245+
const branches = this.state.branches.filter(
246+
branch => !filter || branch.name.includes(filter)
247+
);
240248
return (
241-
<div className={listWrapperClass}>
242-
<List disablePadding>{this._renderItems()}</List>
243-
</div>
249+
<FixedSizeList
250+
height={Math.min(
251+
Math.max(MIN_HEIGHT, branches.length * ITEM_HEIGHT),
252+
MAX_HEIGHT
253+
)}
254+
itemCount={branches.length}
255+
itemData={branches}
256+
itemKey={(index, data) => data[index].name}
257+
itemSize={ITEM_HEIGHT}
258+
style={{ overflowX: 'hidden', paddingTop: 0, paddingBottom: 0 }}
259+
width={'auto'}
260+
>
261+
{this._renderItem}
262+
</FixedSizeList>
244263
);
245264
}
246265

247-
/**
248-
* Renders menu items.
249-
*
250-
* @returns array of React elements
251-
*/
252-
private _renderItems(): React.ReactElement[] {
253-
return this.state.branches.map(this._renderItem, this);
254-
}
255-
256266
/**
257267
* Renders a menu item.
258268
*
259-
* @param branch - branch
260-
* @param idx - item index
269+
* @param props Row properties
261270
* @returns React element
262271
*/
263-
private _renderItem(
264-
branch: Git.IBranch,
265-
idx: number
266-
): React.ReactElement | null {
267-
// Perform a "simple" filter... (TODO: consider implementing fuzzy filtering)
268-
if (this.state.filter && !branch.name.includes(this.state.filter)) {
269-
return null;
270-
}
272+
private _renderItem = (props: ListChildComponentProps): JSX.Element => {
273+
const { data, index, style } = props;
274+
const branch = data[index] as Git.IBranch;
271275
const isActive = branch.name === this.state.current;
272276
return (
273277
<ListItem
@@ -277,8 +281,8 @@ export class BranchMenu extends React.Component<
277281
listItemClass,
278282
isActive ? activeListItemClass : null
279283
)}
280-
key={branch.name}
281284
onClick={this._onBranchClickFactory(branch.name)}
285+
style={style}
282286
>
283287
<span
284288
className={classes(
@@ -290,7 +294,7 @@ export class BranchMenu extends React.Component<
290294
{branch.name}
291295
</ListItem>
292296
);
293-
}
297+
};
294298

295299
/**
296300
* Renders a dialog for creating a new branch.

src/components/FileItem.tsx

Lines changed: 96 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,110 @@ export const STATUS_CODES = {
2525
'!': 'Ignored'
2626
};
2727

28+
/**
29+
* File marker properties
30+
*/
31+
interface IGitMarkBoxProps {
32+
/**
33+
* Filename
34+
*/
35+
fname: string;
36+
/**
37+
* Git repository model
38+
*/
39+
model: GitExtension;
40+
/**
41+
* File status
42+
*/
43+
stage: Git.Status;
44+
}
45+
46+
/**
47+
* Render the selection box in simple mode
48+
*/
49+
class GitMarkBox extends React.PureComponent<IGitMarkBoxProps> {
50+
protected _onClick = (): void => {
51+
// toggle will emit a markChanged signal
52+
this.props.model.toggleMark(this.props.fname);
53+
54+
// needed if markChanged doesn't force an update of a parent
55+
this.forceUpdate();
56+
};
57+
58+
protected _onDoubleClick = (
59+
event: React.MouseEvent<HTMLInputElement>
60+
): void => {
61+
event.stopPropagation();
62+
};
63+
64+
render(): JSX.Element {
65+
// idempotent, will only run once per file
66+
this.props.model.addMark(
67+
this.props.fname,
68+
this.props.stage !== 'untracked'
69+
);
70+
71+
return (
72+
<input
73+
name="gitMark"
74+
className={gitMarkBoxStyle}
75+
type="checkbox"
76+
checked={this.props.model.getMark(this.props.fname)}
77+
onChange={this._onClick}
78+
onDoubleClick={this._onDoubleClick}
79+
/>
80+
);
81+
}
82+
}
83+
84+
/**
85+
* File item properties
86+
*/
2887
export interface IFileItemProps {
88+
/**
89+
* Action buttons on the file
90+
*/
2991
actions?: React.ReactElement;
92+
/**
93+
* Callback to open a context menu on the file
94+
*/
3095
contextMenu?: (file: Git.IStatusFile, event: React.MouseEvent) => void;
96+
/**
97+
* File model
98+
*/
3199
file: Git.IStatusFile;
100+
/**
101+
* Is the file marked?
102+
*/
32103
markBox?: boolean;
104+
/**
105+
* Git repository model
106+
*/
33107
model: GitExtension;
108+
/**
109+
* Callback on double click
110+
*/
34111
onDoubleClick: () => void;
112+
/**
113+
* Is the file selected?
114+
*/
35115
selected?: boolean;
116+
/**
117+
* Callback to select the file
118+
*/
36119
selectFile?: (file: Git.IStatusFile | null) => void;
120+
/**
121+
* Inline styling for the windowing
122+
*/
123+
style: React.CSSProperties;
37124
}
38125

39-
export interface IGitMarkBoxProps {
40-
fname: string;
41-
model: GitExtension;
42-
stage: string;
43-
}
44-
45-
export class FileItem extends React.Component<IFileItemProps> {
46-
getFileChangedLabel(change: keyof typeof STATUS_CODES): string {
126+
export class FileItem extends React.PureComponent<IFileItemProps> {
127+
protected _getFileChangedLabel(change: keyof typeof STATUS_CODES): string {
47128
return STATUS_CODES[change];
48129
}
49130

50-
getFileChangedLabelClass(change: string) {
131+
protected _getFileChangedLabelClass(change: string): string {
51132
if (change === 'M') {
52133
return this.props.selected
53134
? classes(
@@ -67,20 +148,20 @@ export class FileItem extends React.Component<IFileItemProps> {
67148
}
68149
}
69150

70-
getFileClass() {
151+
protected _getFileClass(): string {
71152
return this.props.selected
72153
? classes(fileStyle, selectedFileStyle)
73154
: fileStyle;
74155
}
75156

76-
render() {
157+
render(): JSX.Element {
77158
const { file } = this.props;
78159
const status_code = file.status === 'staged' ? file.x : file.y;
79-
const status = this.getFileChangedLabel(status_code as any);
160+
const status = this._getFileChangedLabel(status_code as any);
80161

81162
return (
82163
<li
83-
className={this.getFileClass()}
164+
className={this._getFileClass()}
84165
onClick={
85166
this.props.selectFile &&
86167
(() => this.props.selectFile(this.props.file))
@@ -92,6 +173,7 @@ export class FileItem extends React.Component<IFileItemProps> {
92173
})
93174
}
94175
onDoubleClick={this.props.onDoubleClick}
176+
style={this.props.style}
95177
title={`${this.props.file.to}${status}`}
96178
>
97179
{this.props.markBox && (
@@ -106,47 +188,10 @@ export class FileItem extends React.Component<IFileItemProps> {
106188
selected={this.props.selected}
107189
/>
108190
{this.props.actions}
109-
<span className={this.getFileChangedLabelClass(this.props.file.y)}>
191+
<span className={this._getFileChangedLabelClass(this.props.file.y)}>
110192
{this.props.file.y === '?' ? 'U' : status_code}
111193
</span>
112194
</li>
113195
);
114196
}
115197
}
116-
117-
export class GitMarkBox extends React.Component<IGitMarkBoxProps> {
118-
constructor(props: IGitMarkBoxProps) {
119-
super(props);
120-
}
121-
122-
protected _onClick = (event: React.ChangeEvent<HTMLInputElement>) => {
123-
// toggle will emit a markChanged signal
124-
this.props.model.toggleMark(this.props.fname);
125-
126-
// needed if markChanged doesn't force an update of a parent
127-
this.forceUpdate();
128-
};
129-
130-
protected _onDoubleClick = (event: React.MouseEvent<HTMLInputElement>) => {
131-
event.stopPropagation();
132-
};
133-
134-
render() {
135-
// idempotent, will only run once per file
136-
this.props.model.addMark(
137-
this.props.fname,
138-
this.props.stage !== 'untracked'
139-
);
140-
141-
return (
142-
<input
143-
name="gitMark"
144-
className={gitMarkBoxStyle}
145-
type="checkbox"
146-
checked={this.props.model.getMark(this.props.fname)}
147-
onChange={this._onClick}
148-
onDoubleClick={this._onDoubleClick}
149-
/>
150-
);
151-
}
152-
}

0 commit comments

Comments
 (0)