Skip to content

Commit 133872d

Browse files
authored
Warn the user of unsaved changes when working with staged files (#1075)
* Modify model for dirty status check on staged file Add necessary members, getters/setters and methods to `model.ts`. Also update `src/tokens.ts` accordingly to adopt those changes. These are for dirty status check on staged files. * Add new component `WarningBox` Add a new component primarily for displaying a warning message. Also add necessary styles for it in `src/style/CommitBox.ts`. * Show dirty staged files warning box in commit box When `dirtyStagedFilesStatusChanged` from the model emits, update `GitPanel`'s state to let it re-render and show/hide the warning box accordingly. The warning box is placed inside `CommitBox`, above other elements. * Prettify all files using Prettier * Adjust `CommitBox.spec.tsx` accordingly Adjust `src/tests/test-components/CommitBox.spec.tsx` accordingly to adopt changes in `ICommitBoxProps`. * Add an endpoint for `/changed_files` Add an endpoint for `/changed_files` for testing/mocking purposes. This is because `GitExtension.refreshDirtyStagedStatus()` calls `GitExtension._changedFiles()` which requires a Git call to `/changed_files` endpoint. * Add a test for `WarningBox` in `CommitBox.spec.tsx` * Prettify using `prettier` * Mock `GitModel.dirtyStagedFilesStatusChanged.connect` Mock `GitModel.dirtyStagedFilesStatusChanged.connect` in `tests/test-components/GitPanel.spec.tsx`. * Move conditional and signal emission to setter Emit the signal in the setter if the value changes. The method `refreshDirtyStagedStatus()` should just invoke the setter instead. * Update docstring * Refresh dirty staged status after refreshing repo "Let's move this after this._setStatus so we take into account the new files status pulled by the status request. That spares us one server request." - /pull/1075#discussion_r805170957 "by calling this after updating the status, we can use `this.status.files.filter( file => file.status === 'staged' || file.status === 'partially-staged' );` to get the staged files" - /pull/1075#discussion_r805171543 * Match docstring with `src/model.ts` * Use `&&` to only render when the condition is true We also avoid rendering an empty `React.Fragment` when the condition is false. * Sync component init state vals with its model "The component state needs to be coherent with model state. So you should initialize the new attribute with the model value (like for the `branches` for example)." - /pull/1075#discussion_r805142546 Initialize `GitPanel` having its initial state be what is defined in its model.
1 parent 5f7f1e3 commit 133872d

File tree

9 files changed

+228
-3
lines changed

9 files changed

+228
-3
lines changed

src/components/CommitBox.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
} from '../style/NewBranchDialog';
2929
import { CommandIDs } from '../tokens';
3030
import { CommitMessage } from './CommitMessage';
31+
import { WarningBox } from './WarningBox';
32+
import { WarningRounded as WarningRoundedIcon } from '@material-ui/icons';
3133

3234
/**
3335
* Commit action
@@ -82,6 +84,11 @@ export interface ICommitBoxProps {
8284
*/
8385
amend: boolean;
8486

87+
/**
88+
* Whether the warning box for dirty (e.g., unsaved) staged files is shown.
89+
*/
90+
warnDirtyStagedFiles: boolean;
91+
8592
/**
8693
* Updates the commit message summary.
8794
*
@@ -185,8 +192,19 @@ export class CommitBox extends React.Component<
185192
'Summary (%1 to commit)',
186193
shortcutHint
187194
);
195+
const dirtyStagedFilesWarningTitle = this.props.trans.__('Warning');
196+
const dirtyStagedFilesWarningContent = this.props.trans.__(
197+
'Looks like you still have unsaved staged files. Remember to save and stage all needed changes before committing!'
198+
);
188199
return (
189200
<div className={classes(commitFormClass, 'jp-git-CommitBox')}>
201+
{this.props.warnDirtyStagedFiles && (
202+
<WarningBox
203+
headerIcon={<WarningRoundedIcon />}
204+
title={dirtyStagedFilesWarningTitle}
205+
content={dirtyStagedFilesWarningContent}
206+
/>
207+
)}
190208
<CommitMessage
191209
trans={this.props.trans}
192210
summary={this.props.summary}

src/components/GitPanel.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ export interface IGitPanelState {
125125
* Amend option toggle
126126
*/
127127
commitAmend: boolean;
128+
129+
/**
130+
* Whether there are dirty (e.g., unsaved) staged files.
131+
*/
132+
hasDirtyStagedFiles: boolean;
128133
}
129134

130135
/**
@@ -139,7 +144,8 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
139144
*/
140145
constructor(props: IGitPanelProps) {
141146
super(props);
142-
const { branches, currentBranch, pathRepository } = props.model;
147+
const { branches, currentBranch, pathRepository, hasDirtyStagedFiles } =
148+
props.model;
143149

144150
this.state = {
145151
branches: branches,
@@ -153,7 +159,8 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
153159
tab: 0,
154160
commitSummary: '',
155161
commitDescription: '',
156-
commitAmend: false
162+
commitAmend: false,
163+
hasDirtyStagedFiles: hasDirtyStagedFiles
157164
};
158165
}
159166

@@ -195,6 +202,12 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
195202
}, this);
196203

197204
settings.changed.connect(this.refreshView, this);
205+
206+
model.dirtyStagedFilesStatusChanged.connect((_, args) => {
207+
this.setState({
208+
hasDirtyStagedFiles: args
209+
});
210+
});
198211
}
199212

200213
componentWillUnmount(): void {
@@ -414,6 +427,7 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
414427
setDescription={this._setCommitDescription}
415428
setAmend={this._setCommitAmend}
416429
onCommit={this.commitFiles}
430+
warnDirtyStagedFiles={this.state.hasDirtyStagedFiles}
417431
/>
418432
</React.Fragment>
419433
);

src/components/WarningBox.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { CardContent } from '@material-ui/core';
2+
import Card from '@material-ui/core/Card';
3+
import CardHeader from '@material-ui/core/CardHeader';
4+
import * as React from 'react';
5+
import {
6+
commitRoot,
7+
dirtyStagedFilesWarningBoxClass,
8+
dirtyStagedFilesWarningBoxContentClass,
9+
dirtyStagedFilesWarningBoxHeaderClass
10+
} from '../style/CommitBox';
11+
12+
/**
13+
* Interface describing the properties of the warning box component.
14+
*/
15+
export interface IWarningBoxProps {
16+
/**
17+
* The warning box's header icon.
18+
*/
19+
headerIcon?: JSX.Element;
20+
/**
21+
* The warning box's header text.
22+
*/
23+
title: string;
24+
/**
25+
* The warning box's content text.
26+
*/
27+
content: string;
28+
}
29+
30+
/**
31+
* Warning box component.
32+
*/
33+
export function WarningBox(props: IWarningBoxProps): JSX.Element {
34+
return (
35+
<Card
36+
classes={{
37+
root: commitRoot
38+
}}
39+
className={dirtyStagedFilesWarningBoxClass}
40+
variant="outlined"
41+
>
42+
<CardHeader
43+
className={dirtyStagedFilesWarningBoxHeaderClass}
44+
avatar={props.headerIcon}
45+
title={props.title}
46+
disableTypography={true}
47+
/>
48+
<CardContent className={dirtyStagedFilesWarningBoxContentClass}>
49+
{props.content}
50+
</CardContent>
51+
</Card>
52+
);
53+
}

src/model.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,36 @@ export class GitExtension implements IGitExtension {
261261
return this._notifyRemoteChanges;
262262
}
263263

264+
/**
265+
* Boolean indicating whether there are dirty staged files
266+
* (e.g., due to unsaved changes on files that have been previously staged).
267+
*/
268+
get hasDirtyStagedFiles(): boolean {
269+
return this._hasDirtyStagedFiles;
270+
}
271+
/**
272+
* Updates the value of the boolean indicating whether there are dirty staged files
273+
* (e.g., due to unsaved changes on files that have been previously staged).
274+
* Emits a signal indicating if necessary.
275+
* This signal is emitted when there is a dirty staged file but none previously,
276+
* and vice versa, when there are no dirty staged files but there were some previously.
277+
*/
278+
set hasDirtyStagedFiles(value: boolean) {
279+
if (this._hasDirtyStagedFiles !== value) {
280+
this._hasDirtyStagedFiles = value;
281+
this._dirtyStagedFilesStatusChanged.emit(value);
282+
}
283+
}
284+
285+
/**
286+
* A signal emitted indicating whether there are dirty (e.g., unsaved) staged files.
287+
* This signal is emitted when there is a dirty staged file but none previously,
288+
* and vice versa, when there are no dirty staged files but there were some previously.
289+
*/
290+
get dirtyStagedFilesStatusChanged(): ISignal<IGitExtension, boolean> {
291+
return this._dirtyStagedFilesStatusChanged;
292+
}
293+
264294
/**
265295
* Get the current markers
266296
*
@@ -954,6 +984,7 @@ export class GitExtension implements IGitExtension {
954984
behind: data.behind || 0,
955985
files
956986
});
987+
await this.refreshDirtyStagedStatus();
957988
} catch (err) {
958989
// TODO we should notify the user
959990
this._clearStatus();
@@ -1041,6 +1072,39 @@ export class GitExtension implements IGitExtension {
10411072
}
10421073
}
10431074

1075+
/**
1076+
* Determines whether there are unsaved changes on staged files,
1077+
* e.g., the user has made changes to a file that has been staged,
1078+
* but has not saved them.
1079+
* @returns promise that resolves upon refreshing the dirty status of staged files
1080+
*/
1081+
async refreshDirtyStagedStatus(): Promise<void> {
1082+
// we assume the repository status has been refreshed prior to this
1083+
1084+
// get staged files
1085+
const stagedFiles = this.status.files.filter(
1086+
file => file.status === 'staged' || file.status === 'partially-staged'
1087+
);
1088+
const fileNames = stagedFiles.map(file => file.to);
1089+
1090+
let result = false;
1091+
1092+
for (const fileName of fileNames) {
1093+
const docWidget = this._docmanager.findWidget(
1094+
this.getRelativeFilePath(fileName)
1095+
);
1096+
if (docWidget !== undefined) {
1097+
const context = this._docmanager.contextForWidget(docWidget);
1098+
if (context.model.dirty) {
1099+
result = true;
1100+
break;
1101+
}
1102+
}
1103+
}
1104+
1105+
this.hasDirtyStagedFiles = result;
1106+
}
1107+
10441108
/**
10451109
* Move files from the "staged" to the "unstaged" area.
10461110
*
@@ -1541,6 +1605,7 @@ export class GitExtension implements IGitExtension {
15411605
private _remoteChangedFiles: Git.IStatusFile[] = [];
15421606
private _changeUpstreamNotified: Git.IStatusFile[] = [];
15431607
private _selectedHistoryFile: Git.IStatusFile | null = null;
1608+
private _hasDirtyStagedFiles = false;
15441609

15451610
private _headChanged = new Signal<IGitExtension, void>(this);
15461611
private _markChanged = new Signal<IGitExtension, void>(this);
@@ -1557,6 +1622,9 @@ export class GitExtension implements IGitExtension {
15571622
IGitExtension,
15581623
Git.IRemoteChangedNotification | null
15591624
>(this);
1625+
private _dirtyStagedFilesStatusChanged = new Signal<IGitExtension, boolean>(
1626+
this
1627+
);
15601628
}
15611629

15621630
export class BranchMarker implements Git.IBranchMarker {

src/style/CommitBox.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,34 @@ export const commitFormClass = style({
1414
borderTop: 'var(--jp-border-width) solid var(--jp-border-color2)'
1515
});
1616

17+
export const dirtyStagedFilesWarningBoxClass = style({
18+
marginBottom: '1em',
19+
padding: 'var(--jp-code-padding)',
20+
21+
outline: 'none',
22+
overflowX: 'auto',
23+
24+
border: 'var(--jp-border-width) solid var(--jp-border-color2)',
25+
borderRadius: '3px'
26+
});
27+
28+
export const dirtyStagedFilesWarningBoxHeaderClass = style({
29+
fontWeight: 'bold',
30+
marginBottom: '0.5em',
31+
color: 'var(--jp-warn-color1)',
32+
padding: 0
33+
});
34+
35+
export const dirtyStagedFilesWarningBoxContentClass = style({
36+
color: 'var(--jp-warn-color1)',
37+
padding: 0,
38+
$nest: {
39+
'&:last-child': {
40+
paddingBottom: 0
41+
}
42+
}
43+
});
44+
1745
export const commitSummaryClass = style({
1846
height: '2em',
1947

src/tokens.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ export interface IGitExtension extends IDisposable {
6161
*/
6262
selectedHistoryFile: Git.IStatusFile | null;
6363

64+
/**
65+
* Boolean indicating whether there are dirty staged files
66+
* (e.g., due to unsaved changes on files that have been previously staged).
67+
*/
68+
hasDirtyStagedFiles: boolean;
69+
6470
/**
6571
* Git repository status.
6672
*/
@@ -92,6 +98,13 @@ export interface IGitExtension extends IDisposable {
9298
Git.IRemoteChangedNotification | null
9399
>;
94100

101+
/**
102+
* A signal emitted indicating whether there are dirty (e.g., unsaved) staged files.
103+
* This signal is emitted when there is a dirty staged file but none in prior,
104+
* and vice versa, when there are no dirty staged files but there were previously.
105+
*/
106+
readonly dirtyStagedFilesStatusChanged: ISignal<IGitExtension, boolean>;
107+
95108
/**
96109
* Add one or more files to the repository staging area.
97110
*
@@ -381,6 +394,15 @@ export interface IGitExtension extends IDisposable {
381394
*/
382395
refreshBranch(): Promise<void>;
383396

397+
/**
398+
* Determines whether there are unsaved changes on staged files,
399+
* e.g., the user has made changes to a file that has been staged,
400+
* but has not saved them.
401+
* Emits a signal indicating if there are unsaved changes.
402+
* @returns promise that resolves upon refreshing the dirty status of staged files
403+
*/
404+
refreshDirtyStagedStatus(): Promise<void>;
405+
384406
/**
385407
* Request Git status refresh
386408
*/

tests/test-components/CommitBox.spec.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'jest';
66
import * as React from 'react';
77
import { CommitBox, ICommitBoxProps } from '../../src/components/CommitBox';
88
import { CommitMessage } from '../../src/components/CommitMessage';
9+
import { WarningBox } from '../../src/components/WarningBox';
910
import { CommandIDs } from '../../src/tokens';
1011

1112
describe('CommitBox', () => {
@@ -29,7 +30,8 @@ describe('CommitBox', () => {
2930
hasFiles: false,
3031
commands: defaultCommands,
3132
trans: trans,
32-
label: 'Commit'
33+
label: 'Commit',
34+
warnDirtyStagedFiles: false
3335
};
3436

3537
describe('#constructor()', () => {
@@ -135,5 +137,14 @@ describe('CommitBox', () => {
135137
const prop = node.prop('disabled');
136138
expect(prop).toEqual(false);
137139
});
140+
141+
it('should render a warning box when there are dirty staged files', () => {
142+
const props = {
143+
...defaultProps,
144+
warnDirtyStagedFiles: true
145+
};
146+
const component = shallow(<CommitBox {...props} />);
147+
expect(component.find(WarningBox).length).toEqual(1);
148+
});
138149
});
139150
});

tests/test-components/GitPanel.spec.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ describe('GitPanel', () => {
250250
},
251251
notifyRemoteChanges: {
252252
connect: jest.fn()
253+
},
254+
dirtyStagedFilesStatusChanged: {
255+
connect: jest.fn()
253256
}
254257
} as any;
255258

tests/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export const defaultMockedResponses: {
3232
};
3333
}
3434
},
35+
changed_files: {
36+
body: () => {
37+
return {
38+
code: 0,
39+
files: []
40+
};
41+
}
42+
},
3543
show_prefix: {
3644
body: () => {
3745
return {

0 commit comments

Comments
 (0)