Skip to content

Commit d1f9f4b

Browse files
authored
Display tag similarly to branches (#788)
* Display tag similarly to branches * Fix styling * Fix unit test
1 parent 1829b1d commit d1f9f4b

File tree

6 files changed

+402
-201
lines changed

6 files changed

+402
-201
lines changed

src/components/TagMenu.tsx

+309
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
2+
import List from '@material-ui/core/List';
3+
import ListItem from '@material-ui/core/ListItem';
4+
import ClearIcon from '@material-ui/icons/Clear';
5+
import * as React from 'react';
6+
import { FixedSizeList, ListChildComponentProps } from 'react-window';
7+
import { Logger } from '../logger';
8+
import {
9+
filterClass,
10+
filterClearClass,
11+
filterInputClass,
12+
filterWrapperClass,
13+
listItemClass,
14+
listItemIconClass,
15+
wrapperClass
16+
} from '../style/BranchMenu';
17+
import { tagIcon } from '../style/icons';
18+
import { IGitExtension, Level } from '../tokens';
19+
20+
const CHANGES_ERR_MSG =
21+
'The repository contains files with uncommitted changes. Please commit or discard these changes before switching to a tag.';
22+
const ITEM_HEIGHT = 24.8; // HTML element height for a single branch
23+
const MIN_HEIGHT = 150; // Minimal HTML element height for the tags list
24+
const MAX_HEIGHT = 400; // Maximal HTML element height for the tags list
25+
26+
/**
27+
* Callback invoked upon encountering an error when switching tags.
28+
*
29+
* @private
30+
* @param error - error
31+
* @param logger - the logger
32+
*/
33+
function onTagError(error: any, logger: Logger): void {
34+
if (error.message.includes('following files would be overwritten')) {
35+
// Empty log message to hide the executing alert
36+
logger.log({
37+
message: '',
38+
level: Level.INFO
39+
});
40+
showDialog({
41+
title: 'Unable to checkout tag',
42+
body: (
43+
<React.Fragment>
44+
<p>
45+
Your changes to the following files would be overwritten by
46+
switching:
47+
</p>
48+
<List>
49+
{error.message
50+
.split('\n')
51+
.slice(1, -3)
52+
.map(renderFileName)}
53+
</List>
54+
<span>
55+
Please commit, stash, or discard your changes before you checkout
56+
tags.
57+
</span>
58+
</React.Fragment>
59+
),
60+
buttons: [Dialog.okButton({ label: 'Dismiss' })]
61+
});
62+
} else {
63+
logger.log({
64+
level: Level.ERROR,
65+
message: 'Failed to checkout tag.',
66+
error
67+
});
68+
}
69+
}
70+
71+
/**
72+
* Renders a file name.
73+
*
74+
* @private
75+
* @param filename - file name
76+
* @returns React element
77+
*/
78+
function renderFileName(filename: string): React.ReactElement {
79+
return <ListItem key={filename}>{filename}</ListItem>;
80+
}
81+
82+
/**
83+
* Interface describing component properties.
84+
*/
85+
export interface ITagMenuProps {
86+
/**
87+
* Boolean indicating whether branching is disabled.
88+
*/
89+
branching: boolean;
90+
91+
/**
92+
* Extension logger
93+
*/
94+
logger: Logger;
95+
96+
/**
97+
* Git extension data model.
98+
*/
99+
model: IGitExtension;
100+
}
101+
102+
/**
103+
* Interface describing component state.
104+
*/
105+
export interface ITagMenuState {
106+
/**
107+
* Menu filter.
108+
*/
109+
filter: string;
110+
111+
/**
112+
* Current list of tags.
113+
*/
114+
tags: string[];
115+
}
116+
117+
/**
118+
* React component for rendering a branch menu.
119+
*/
120+
export class TagMenu extends React.Component<ITagMenuProps, ITagMenuState> {
121+
/**
122+
* Returns a React component for rendering a branch menu.
123+
*
124+
* @param props - component properties
125+
* @returns React component
126+
*/
127+
constructor(props: ITagMenuProps) {
128+
super(props);
129+
130+
this.state = {
131+
filter: '',
132+
tags: []
133+
};
134+
}
135+
136+
componentDidMount() {
137+
this.props.model
138+
.tags()
139+
.then(response => {
140+
this.setState({
141+
tags: response.tags
142+
});
143+
})
144+
.catch(error => {
145+
console.error(error);
146+
this.setState({
147+
tags: []
148+
});
149+
showErrorMessage('Fail to get the tags.', error);
150+
});
151+
}
152+
153+
/**
154+
* Renders the component.
155+
*
156+
* @returns React element
157+
*/
158+
render(): React.ReactElement {
159+
return (
160+
<div className={wrapperClass}>
161+
{this._renderFilter()}
162+
{this._renderTagList()}
163+
</div>
164+
);
165+
}
166+
167+
/**
168+
* Renders a branch input filter.
169+
*
170+
* @returns React element
171+
*/
172+
private _renderFilter(): React.ReactElement {
173+
return (
174+
<div className={filterWrapperClass}>
175+
<div className={filterClass}>
176+
<input
177+
className={filterInputClass}
178+
type="text"
179+
onChange={this._onFilterChange}
180+
value={this.state.filter}
181+
placeholder="Filter"
182+
title="Filter branch menu"
183+
/>
184+
{this.state.filter ? (
185+
<button className={filterClearClass}>
186+
<ClearIcon
187+
titleAccess="Clear the current filter"
188+
fontSize="small"
189+
onClick={this._resetFilter}
190+
/>
191+
</button>
192+
) : null}
193+
</div>
194+
</div>
195+
);
196+
}
197+
198+
/**
199+
* Renders a
200+
*
201+
* @returns React element
202+
*/
203+
private _renderTagList(): React.ReactElement {
204+
// Perform a "simple" filter... (TODO: consider implementing fuzzy filtering)
205+
const filter = this.state.filter;
206+
const tags = this.state.tags.filter(tag => !filter || tag.includes(filter));
207+
return (
208+
<FixedSizeList
209+
height={Math.min(
210+
Math.max(MIN_HEIGHT, tags.length * ITEM_HEIGHT),
211+
MAX_HEIGHT
212+
)}
213+
itemCount={tags.length}
214+
itemData={tags}
215+
itemKey={(index, data) => data[index]}
216+
itemSize={ITEM_HEIGHT}
217+
style={{ overflowX: 'hidden', paddingTop: 0, paddingBottom: 0 }}
218+
width={'auto'}
219+
>
220+
{this._renderItem}
221+
</FixedSizeList>
222+
);
223+
}
224+
225+
/**
226+
* Renders a menu item.
227+
*
228+
* @param props Row properties
229+
* @returns React element
230+
*/
231+
private _renderItem = (props: ListChildComponentProps): JSX.Element => {
232+
const { data, index, style } = props;
233+
const tag = data[index] as string;
234+
235+
return (
236+
<ListItem
237+
button
238+
title={`Checkout to tag: ${tag}`}
239+
className={listItemClass}
240+
onClick={this._onTagClickFactory(tag)}
241+
style={style}
242+
>
243+
<tagIcon.react className={listItemIconClass} tag="span" />
244+
{tag}
245+
</ListItem>
246+
);
247+
};
248+
249+
/**
250+
* Callback invoked upon a change to the menu filter.
251+
*
252+
* @param event - event object
253+
*/
254+
private _onFilterChange = (event: any): void => {
255+
this.setState({
256+
filter: event.target.value
257+
});
258+
};
259+
260+
/**
261+
* Callback invoked to reset the menu filter.
262+
*/
263+
private _resetFilter = (): void => {
264+
this.setState({
265+
filter: ''
266+
});
267+
};
268+
269+
/**
270+
* Returns a callback which is invoked upon clicking a tag.
271+
*
272+
* @param branch - tag
273+
* @returns callback
274+
*/
275+
private _onTagClickFactory(tag: string) {
276+
const self = this;
277+
return onClick;
278+
279+
/**
280+
* Callback invoked upon clicking a tag.
281+
*
282+
* @private
283+
* @param event - event object
284+
* @returns promise which resolves upon attempting to switch tags
285+
*/
286+
async function onClick(): Promise<void> {
287+
if (!self.props.branching) {
288+
showErrorMessage('Checkout tags is disabled', CHANGES_ERR_MSG);
289+
return;
290+
}
291+
292+
self.props.logger.log({
293+
level: Level.RUNNING,
294+
message: 'Checking tag out...'
295+
});
296+
297+
try {
298+
await self.props.model.checkoutTag(tag);
299+
} catch (err) {
300+
return onTagError(err, self.props.logger);
301+
}
302+
303+
self.props.logger.log({
304+
level: Level.SUCCESS,
305+
message: 'Tag checkout.'
306+
});
307+
}
308+
}
309+
}

0 commit comments

Comments
 (0)