@jupyterlab/git
Version:
A JupyterLab extension for version control using git
667 lines (666 loc) • 31.5 kB
JavaScript
import { Dialog, Notification, showDialog } from '@jupyterlab/apputils';
import { PathExt } from '@jupyterlab/coreutils';
import { Signal } from '@lumino/signaling';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import * as React from 'react';
import { showError } from '../notifications';
import { hiddenButtonStyle } from '../style/ActionButtonStyle';
import { panelWrapperClass, panelMainClass, repoButtonClass, warningTextClass } from '../style/GitPanel';
import { addIcon, rewindIcon, trashIcon } from '../style/icons';
import { CommandIDs, Git } from '../tokens';
import { openFileDiff, stopPropagationWrapper } from '../utils';
import { GitAuthorForm } from '../widgets/AuthorBox';
import { ActionButton } from './ActionButton';
import { CommitBox } from './CommitBox';
import { CommitComparisonBox } from './CommitComparisonBox';
import { FileList } from './FileList';
import { GitStash } from './GitStash';
import { HistorySideBar } from './HistorySideBar';
import { BranchMenu } from './BranchMenu';
import { RebaseAction } from './RebaseAction';
import { WarningBox } from './WarningBox';
import { TagMenu } from './TagMenu';
/**
* React component for rendering a panel for performing Git operations.
*/
export class GitPanel extends React.Component {
/**
* Returns a React component for rendering a panel for performing Git operations.
*
* @param props - component properties
* @returns React component
*/
constructor(props) {
super(props);
this.refreshBranches = async () => {
this.setState({
branches: this.props.model.branches
});
};
this.refreshCurrentBranch = async () => {
const { currentBranch } = this.props.model;
this.setState({
currentBranch: currentBranch ? currentBranch.name : 'main',
referenceCommit: null,
challengerCommit: null
});
};
this.refreshTags = async () => {
this.setState({
tagsList: this.props.model.tagsList
});
};
this.refreshHistory = async () => {
var _a;
if (this.props.model.pathRepository !== null) {
// Get git log for current branch
const logData = await this.props.model.log(this.props.settings.composite['historyCount']);
let pastCommits = new Array();
if (logData.code === 0) {
pastCommits = (_a = logData.commits) !== null && _a !== void 0 ? _a : [];
}
this.setState({
pastCommits: pastCommits
});
}
};
this.refreshSubmodules = async () => {
await this.props.model.listSubmodules();
this.setState({
submodules: this.props.model.submodules
});
};
/**
* Refresh widget, update all content
*/
this.refreshView = async () => {
if (this.props.model.pathRepository === null) {
return;
}
if (this.props.contentMode === 'changes') {
// 'changes' state is driven by statusChanged/stashChanged/dirtyFilesStatusChanged;
// a forceUpdate here covers setting-driven re-renders (e.g. commitAndPush, simpleStaging).
this.forceUpdate();
return;
}
await this.refreshBranches();
await this.refreshHistory();
await this.refreshTags();
};
/**
* Commits files.
*
* @returns a promise which commits changes
*/
this.commitFiles = async () => {
let msg = this.state.commitSummary;
// Only include description if not empty
if (this.state.commitDescription) {
msg = msg + '\n\n' + this.state.commitDescription + '\n';
}
if (!msg && !this.state.commitAmend) {
return;
}
const commit = this.props.settings.composite['simpleStaging']
? this._commitMarkedFiles
: this._commitStagedFiles;
try {
if (this.state.commitAmend) {
await commit(null);
}
else {
await commit(msg);
}
// Only erase commit message upon success
this.setState({
commitSummary: '',
commitDescription: ''
});
}
catch (error) {
console.error(error);
}
};
this._gitStashClear = async () => {
await this.props.model.dropStash();
};
this._gitStashApplyLatest = async () => {
await this.props.model.applyStash(0);
};
/**
* Callback invoked upon clicking a button to stash the dirty files.
*
* @param event - event object
* @returns a promise which resolves upon stashing the latest changes
*/
this._onStashClick = async () => {
await this.props.commands.execute(CommandIDs.gitStash);
};
/**
* Updates the commit message description.
*
* @param description - commit message description
*/
this._setCommitDescription = (description) => {
this.setState({
commitDescription: description
});
};
/**
* Updates the commit message summary.
*
* @param summary - commit message summary
*/
this._setCommitSummary = (summary) => {
this.setState({
commitSummary: summary
});
};
/**
* Updates the amend option
*
* @param amend - whether the amend is checked
*/
this._setCommitAmend = (amend) => {
this.setState({
commitAmend: amend
});
};
/**
* Commits all marked files.
*
* @param message - commit message
* @returns a promise which commits the files
*/
this._commitMarkedFiles = async (message) => {
const id = Notification.emit(this.props.trans.__('Staging files...'), 'in-progress', { autoClose: false });
await this.props.model.reset();
await this.props.model.add(...this._markedFiles.map(file => file.to));
await this._commitStagedFiles(message, id);
};
/**
* Commits all staged files.
*
* @param message - commit message
* @returns a promise which commits the files
*/
this._commitStagedFiles = async (message = null, notificationId) => {
const errorMsg = this.props.trans.__('Failed to commit changes.');
let id = notificationId !== null && notificationId !== void 0 ? notificationId : null;
try {
const author = await this._hasIdentity(this.props.model.pathRepository);
const notebooksWithOutputs = await this.props.model.checkNotebooksForOutputs();
const clearSetting = this.props.settings.composite['clearOutputsBeforeCommit'];
if (notebooksWithOutputs.length > 0 &&
(clearSetting === null || clearSetting === undefined)) {
const dialog = new Dialog({
title: this.props.trans.__('Notebook outputs detected'),
checkbox: {
label: this.props.trans.__('Always clear outputs before committing?'),
checked: false
},
buttons: [
Dialog.cancelButton({
label: this.props.trans.__('Keep Outputs & Commit')
}),
Dialog.okButton({ label: this.props.trans.__('Clean & Commit') })
],
defaultButton: 0
});
const result = await dialog.launch();
dialog.dispose();
if (result.button.label === this.props.trans.__('Cancel')) {
return;
}
if (result.button.accept) {
const accepted = true;
if (result === null || result === void 0 ? void 0 : result.isChecked) {
this.props.settings.set('clearOutputsBeforeCommit', accepted);
}
id = Notification.emit(this.props.trans.__('Cleaning notebook outputs…'), 'in-progress', { autoClose: false });
await this.props.model.stripNotebooksOutputs(notebooksWithOutputs);
}
}
else if (clearSetting === true) {
// Always clean before commit
id = Notification.emit(this.props.trans.__('Cleaning notebook outputs…'), 'in-progress', { autoClose: false });
await this.props.model.stripNotebooksOutputs(notebooksWithOutputs);
}
const notificationMsg = this.props.trans.__('Committing changes...');
if (id !== null) {
Notification.update({
id,
message: notificationMsg,
autoClose: false
});
}
else {
id = Notification.emit(notificationMsg, 'in-progress', {
autoClose: false
});
}
if (this.state.commitAmend) {
await this.props.model.commit(null, true, author);
}
else {
await this.props.model.commit(message, false, author);
}
Notification.update({
id,
type: 'success',
message: this.props.trans.__('Committed changes.'),
autoClose: 5000
});
const hasRemote = this.props.model.branches.some(branch => branch.is_remote_branch);
// If enabled commit and push, push here
if (this.props.settings.composite['commitAndPush'] && hasRemote) {
await this.props.commands.execute(CommandIDs.gitPush);
}
}
catch (error) {
if (id === null) {
Notification.error(errorMsg, showError(error, this.props.trans));
}
else {
Notification.update({
id,
message: errorMsg,
...showError(error, this.props.trans)
});
}
throw error;
}
};
this._previousRepoPath = null;
const { branches, currentBranch, pathRepository, hasDirtyFiles: hasDirtyStagedFiles, stash, tagsList, submodules: submodules } = props.model;
this.state = {
branches: branches,
currentBranch: currentBranch ? currentBranch.name : 'main',
files: [],
remoteChangedFiles: [],
nCommitsAhead: 0,
nCommitsBehind: 0,
pastCommits: [],
repository: pathRepository,
commitSummary: '',
commitDescription: '',
commitAmend: false,
hasDirtyFiles: hasDirtyStagedFiles,
referenceCommit: null,
challengerCommit: null,
stash: stash,
tagsList: tagsList,
submodules: submodules
};
}
/**
* Callback invoked immediately after mounting a component (i.e., inserting into a tree).
*/
componentDidMount() {
var _a, _b, _c;
const { model, settings, contentMode } = this.props;
model.repositoryChanged.connect((_, args) => {
this.setState({
repository: args.newValue,
referenceCommit: null,
challengerCommit: null
});
this.refreshView();
}, this);
settings.changed.connect(this.refreshView, this);
if (contentMode === 'changes') {
model.statusChanged.connect(async () => {
const remotechangedFiles = await model.remoteChangedFiles();
this.setState({
files: model.status.files,
remoteChangedFiles: remotechangedFiles,
nCommitsAhead: model.status.ahead,
nCommitsBehind: model.status.behind
});
}, this);
model.stashChanged.connect((_, args) => {
this.setState({
stash: args.newValue
});
}, this);
model.dirtyFilesStatusChanged.connect((_, args) => {
this.setState({
hasDirtyFiles: args
});
}, this);
model.remoteChanged.connect((_, args) => {
this.warningDialog(args);
}, this);
model.repositoryChanged.connect(async () => {
await this.refreshSubmodules();
}, this);
}
if (contentMode === 'history') {
model.branchesChanged.connect(async () => {
await this.refreshBranches();
}, this);
model.tagsChanged.connect(async () => {
await this.refreshTags();
}, this);
model.headChanged.connect(async () => {
await this.refreshCurrentBranch();
await this.refreshHistory();
}, this);
model.selectedHistoryFileChanged.connect(() => {
this.refreshHistory();
}, this);
}
if (contentMode === 'branches') {
model.statusChanged.connect(() => {
this.setState({
files: model.status.files
});
}, this);
model.branchesChanged.connect(async () => {
await this.refreshBranches();
}, this);
model.tagsChanged.connect(async () => {
await this.refreshTags();
}, this);
model.headChanged.connect(async () => {
await this.refreshCurrentBranch();
await this.refreshHistory();
}, this);
}
// Seed state from the current model and trigger fetches for the active
// `contentMode`, in case the relevant signals fired before this mount.
if ((_a = model.status) === null || _a === void 0 ? void 0 : _a.files) {
if (contentMode === 'changes') {
this.setState({
files: model.status.files,
nCommitsAhead: (_b = model.status.ahead) !== null && _b !== void 0 ? _b : 0,
nCommitsBehind: (_c = model.status.behind) !== null && _c !== void 0 ? _c : 0
});
}
else if (contentMode === 'branches') {
this.setState({
files: model.status.files
});
}
}
if (model.pathRepository !== null) {
const onError = (error) => {
console.error('Failed to refresh Git panel on mount.', error);
};
if (contentMode === 'history') {
this.refreshHistory().catch(onError);
}
if (contentMode === 'branches') {
this.refreshBranches().catch(onError);
this.refreshTags().catch(onError);
this.refreshHistory().catch(onError);
}
}
}
componentWillUnmount() {
// Clear all signal connections
Signal.clearData(this);
}
/**
* Renders the component.
*
* @returns React element
*/
render() {
var _a;
if (this.state.repository === null) {
if ((_a = this.props.showNoRepositoryWarning) !== null && _a !== void 0 ? _a : false) {
return React.createElement("div", { className: panelWrapperClass }, this._renderWarning());
}
return React.createElement("div", { className: panelWrapperClass });
}
return React.createElement("div", { className: panelWrapperClass }, this._renderMain());
}
_renderMain() {
const { contentMode } = this.props;
if (contentMode === 'changes') {
return React.createElement("div", { className: panelMainClass }, this._renderChanges());
}
if (contentMode === 'history') {
return React.createElement("div", { className: panelMainClass }, this._renderHistory());
}
return React.createElement("div", { className: panelMainClass }, this._renderBranches());
}
_renderBranches() {
const branching = !(this.props.settings.composite['disableBranchWithChanges'] &&
(this._hasUnStagedFile() || this._hasStagedFile()));
return (React.createElement(React.Fragment, null,
React.createElement(BranchMenu, { currentBranch: this.state.currentBranch, branches: this.state.branches, branching: branching, commands: this.props.commands, model: this.props.model, trans: this.props.trans }),
React.createElement(TagMenu, { pastCommits: this.state.pastCommits, tagsList: this.state.tagsList, model: this.props.model, branching: branching, trans: this.props.trans })));
}
_renderChanges() {
var _a, _b;
const hasRemote = this.props.model.branches.some(branch => branch.is_remote_branch);
const commitAndPush = this.props.settings.composite['commitAndPush'] && hasRemote;
const buttonLabel = commitAndPush
? this.state.commitAmend
? this.props.trans.__('Commit (Amend) and Push')
: this.props.trans.__('Commit and Push')
: this.state.commitAmend
? this.props.trans.__('Commit (Amend)')
: this.props.trans.__('Commit');
const warningTitle = this.props.trans.__('Warning');
const inSimpleMode = this.props.settings.composite['simpleStaging'];
const warningContent = inSimpleMode
? this.props.trans.__('You have unsaved tracked files. You probably want to save all changes before committing.')
: this.props.trans.__('You have unsaved staged files. You probably want to save and stage all needed changes before committing.');
return (React.createElement(React.Fragment, null,
React.createElement(FileList, { files: this._sortedFiles, model: this.props.model, commands: this.props.commands, settings: this.props.settings, trans: this.props.trans }),
React.createElement(GitStash, { actions: React.createElement(React.Fragment, null,
React.createElement(ActionButton, { className: hiddenButtonStyle, icon: rewindIcon, onClick: this._onStashClick, title: this.props.trans.__('Stash latest changes') }),
React.createElement(ActionButton, { icon: addIcon, className: hiddenButtonStyle, disabled: ((_a = this.props.model.stash) === null || _a === void 0 ? void 0 : _a.length) === 0, title: this.props.trans.__('Apply the latest stash'), onClick: stopPropagationWrapper(() => {
this._gitStashApplyLatest();
}) }),
React.createElement(ActionButton, { className: hiddenButtonStyle, icon: trashIcon, title: this.props.trans.__('Clear the entire stash'), disabled: ((_b = this.props.model.stash) === null || _b === void 0 ? void 0 : _b.length) === 0, onClick: stopPropagationWrapper(() => {
this._gitStashClear();
}) })), stash: this.props.model.stash, model: this.props.model, height: 100, collapsible: true, trans: this.props.trans }),
this.props.model.status.state !== Git.State.REBASING ? (React.createElement(CommitBox, { commands: this.props.commands, hasFiles: inSimpleMode
? this._markedFiles.length > 0
: this._hasStagedFile(), trans: this.props.trans, label: buttonLabel, summary: this.state.commitSummary, description: this.state.commitDescription, amend: this.state.commitAmend, setSummary: this._setCommitSummary, setDescription: this._setCommitDescription, setAmend: this._setCommitAmend, onCommit: this.commitFiles, warning: this.state.hasDirtyFiles ? (React.createElement(WarningBox, { headerIcon: React.createElement(WarningRoundedIcon, null), title: warningTitle, content: warningContent })) : null })) : (React.createElement(RebaseAction, { commands: this.props.commands, hasConflict: this.state.files.some(file => file.status === 'unmerged'), trans: this.props.trans }))));
}
_renderHistory() {
return (React.createElement(React.Fragment, null,
React.createElement(HistorySideBar, { branches: this.state.branches, tagsList: this.state.tagsList, commits: this.state.pastCommits, model: this.props.model, commands: this.props.commands, trans: this.props.trans, referenceCommit: this.state.referenceCommit, challengerCommit: this.state.challengerCommit, onSelectForCompare: commit => async (event) => {
event === null || event === void 0 ? void 0 : event.stopPropagation();
this.setState({ referenceCommit: commit }, () => {
this._openSingleFileComparison(event);
});
}, onCompareWithSelected: commit => async (event) => {
event === null || event === void 0 ? void 0 : event.stopPropagation();
this.setState({ challengerCommit: commit }, () => {
this._openSingleFileComparison(event);
});
} }),
this.props.model.selectedHistoryFile === null &&
(this.state.referenceCommit || this.state.challengerCommit) && (React.createElement(CommitComparisonBox, { header: this.props.trans.__('Compare %1 and %2', this.state.referenceCommit
? this.state.referenceCommit.commit.substring(0, 7)
: '...', this.state.challengerCommit
? this.state.challengerCommit.commit.substring(0, 7)
: '...'), referenceCommit: this.state.referenceCommit, challengerCommit: this.state.challengerCommit, commands: this.props.commands, model: this.props.model, trans: this.props.trans, onClose: event => {
event === null || event === void 0 ? void 0 : event.stopPropagation();
this.setState({
referenceCommit: null,
challengerCommit: null
});
}, onOpenDiff: this.state.referenceCommit && this.state.challengerCommit
? openFileDiff(this.props.commands)(this.state.challengerCommit, this.state.referenceCommit)
: undefined }))));
}
/**
* Renders a panel for prompting a user to find a Git repository.
*
* @returns React element
*/
_renderWarning() {
const path = this.props.filebrowser.path;
const { commands } = this.props;
return (React.createElement(React.Fragment, null,
React.createElement("div", { className: warningTextClass },
path ? (React.createElement(React.Fragment, null,
React.createElement("b", { title: path }, PathExt.basename(path)),
' ',
this.props.trans.__('is not'))) : (this.props.trans.__('You are not currently in')),
this.props.trans.__(' a Git repository. To use Git, navigate to a local repository, initialize a repository here, or clone an existing repository.')),
React.createElement("button", { className: repoButtonClass, onClick: () => commands.execute('filebrowser:toggle-main') }, this.props.trans.__('Open the FileBrowser')),
React.createElement("button", { className: repoButtonClass, onClick: () => commands.execute(CommandIDs.gitInit) }, this.props.trans.__('Initialize a Repository')),
commands.hasCommand(CommandIDs.gitClone) && (React.createElement("button", { className: repoButtonClass, onClick: async () => {
await commands.execute(CommandIDs.gitClone);
await commands.execute('filebrowser:toggle-main');
} }, this.props.trans.__('Clone a Repository')))));
}
/**
* Determines whether a user has a known Git identity.
*
* @param path - repository path
*/
async _hasIdentity(path) {
var _a, _b;
if (path === null) {
return null;
}
const isIdentityValid = !!((_a = this.props.model.lastAuthor) === null || _a === void 0 ? void 0 : _a.name) &&
!!((_b = this.props.model.lastAuthor) === null || _b === void 0 ? void 0 : _b.email);
// If the repository path changes, is explicitly configured, or the last authors identity is invalid check the user identity
if (path !== this._previousRepoPath ||
this.props.settings.composite['promptUserIdentity'] ||
!isIdentityValid) {
try {
let userOrEmailNotSet = false;
let author;
let authorOverride = null;
if (this.props.model.lastAuthor === null || !isIdentityValid) {
const data = (await this.props.model.config());
const options = data['options'];
author = {
name: options['user.name'] || '',
email: options['user.email'] || ''
};
userOrEmailNotSet = !author.name || !author.email;
}
else {
author = this.props.model.lastAuthor;
}
// If explicitly configured or the user name or e-mail is unknown, ask the user to set it
if (this.props.settings.composite['promptUserIdentity'] ||
userOrEmailNotSet) {
const result = await showDialog({
title: this.props.trans.__('Who is committing?'),
body: new GitAuthorForm({ author, trans: this.props.trans })
});
if (!result.button.accept) {
throw new Error(this.props.trans.__('User refused to set identity.'));
}
author = result.value;
if (userOrEmailNotSet) {
await this.props.model.config({
'user.name': author.name,
'user.email': author.email
});
}
this.props.model.lastAuthor = author;
if (this.props.settings.composite['promptUserIdentity']) {
authorOverride = `${author.name} <${author.email}>`;
}
}
this._previousRepoPath = path;
return authorOverride;
}
catch (error) {
if (error instanceof Git.GitResponseError) {
throw error;
}
throw new Error(
// @ts-expect-error error will have message attribute
this.props.trans.__('Failed to set your identity. %1', error.message));
}
}
return null;
}
_hasStagedFile() {
return this.state.files.some(file => file.status === 'staged' || file.status === 'partially-staged');
}
_hasUnStagedFile() {
return this.state.files.some(file => file.status === 'unstaged' || file.status === 'partially-staged');
}
/**
* List of marked files.
*/
get _markedFiles() {
return this._sortedFiles.filter(file => this.props.model.getMark(file.to));
}
/**
* List of sorted modified files.
*/
get _sortedFiles() {
const { files, remoteChangedFiles } = this.state;
let sfiles = files;
if (remoteChangedFiles) {
sfiles = sfiles.concat(remoteChangedFiles);
}
sfiles.sort((a, b) => a.to.localeCompare(b.to));
return sfiles;
}
/**
* Show a dialog when a notifyRemoteChanges signal is emitted from the model.
*/
async warningDialog(options) {
const title = this.props.trans.__('One or more open files are behind %1 head. Do you want to pull the latest remote version?', this.props.model.status.remote);
const dialog = new Dialog({
title,
body: this._renderBody(options.notNotified, options.notified),
buttons: [
Dialog.cancelButton({
label: this.props.trans.__('Continue Without Pulling')
}),
Dialog.warnButton({
label: this.props.trans.__('Pull'),
caption: this.props.trans.__('Git Pull from Remote Branch')
})
]
});
const result = await dialog.launch();
if (result.button.accept) {
await this.props.commands.execute(CommandIDs.gitPull, {});
}
}
/**
* renders the body to be used in the remote changes warning dialog
*/
_renderBody(notNotifiedList, notifiedList = []) {
const listedItems = notNotifiedList.map((item) => {
console.log(item.to);
const item_val = this.props.trans.__(item.to);
return React.createElement("li", { key: item_val }, item_val);
});
let elem = React.createElement("ul", null, listedItems);
if (notifiedList.length > 0) {
const remaining = this.props.trans.__('The following open files remain behind:');
const alreadyListedItems = notifiedList.map((item) => {
console.log(item.to);
const item_val = this.props.trans.__(item.to);
return React.createElement("li", { key: item_val }, item_val);
});
const full = (React.createElement("div", null,
elem,
remaining,
React.createElement("ul", null, alreadyListedItems)));
elem = full;
}
return React.createElement("div", null, elem);
}
/**
*
*/
_openSingleFileComparison(event) {
if (this.props.model.selectedHistoryFile &&
this.state.referenceCommit &&
this.state.challengerCommit) {
openFileDiff(this.props.commands)(this.state.challengerCommit, this.state.referenceCommit)(this.props.model.selectedHistoryFile.to, !this.props.model.selectedHistoryFile.is_binary)(event);
}
}
}