@jupyterlab/git
Version:
A JupyterLab extension for version control using git
618 lines (617 loc) • 28.1 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 Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import * as React from 'react';
import { showError } from '../notifications';
import { hiddenButtonStyle } from '../style/ActionButtonStyle';
import { panelWrapperClass, repoButtonClass, selectedTabClass, tabClass, tabIndicatorClass, tabsClass, 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 { RebaseAction } from './RebaseAction';
import { Toolbar } from './Toolbar';
import { WarningBox } from './WarningBox';
/**
* 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) {
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);
};
/**
* Callback invoked upon changing the active panel tab.
*
* @param event - event object
* @param tab - tab number
*/
this._onTabChange = (event, tab) => {
if (tab === 1) {
this.refreshHistory();
}
this.setState({
tab: tab
});
};
/**
* 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 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,
tab: 0,
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() {
const { model, settings } = this.props;
model.stashChanged.connect((_, args) => {
this.setState({
stash: args.newValue
});
}, this);
model.repositoryChanged.connect((_, args) => {
this.setState({
repository: args.newValue,
referenceCommit: null,
challengerCommit: null
});
this.refreshView();
}, this);
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.branchesChanged.connect(async () => {
await this.refreshBranches();
}, this);
model.headChanged.connect(async () => {
await this.refreshCurrentBranch();
if (this.state.tab === 1) {
this.refreshHistory();
}
}, this);
model.tagsChanged.connect(async () => {
await this.refreshTags();
}, this);
model.selectedHistoryFileChanged.connect(() => {
this.setState({ tab: 1 });
this.refreshHistory();
}, this);
model.remoteChanged.connect((_, args) => {
this.warningDialog(args);
}, this);
model.repositoryChanged.connect(async () => {
await this.refreshSubmodules();
}, this);
settings.changed.connect(this.refreshView, this);
model.dirtyFilesStatusChanged.connect((_, args) => {
this.setState({
hasDirtyFiles: args
});
});
}
componentWillUnmount() {
// Clear all signal connections
Signal.clearData(this);
}
/**
* Renders the component.
*
* @returns React element
*/
render() {
return (React.createElement("div", { className: panelWrapperClass }, this.state.repository !== null ? (React.createElement(React.Fragment, null,
this._renderToolbar(),
this._renderMain())) : (this._renderWarning())));
}
/**
* Renders a toolbar.
*
* @returns React element
*/
_renderToolbar() {
const disableBranching = Boolean(this.props.settings.composite['disableBranchWithChanges'] &&
(this._hasUnStagedFile() || this._hasStagedFile()));
return (React.createElement(Toolbar, { currentBranch: this.state.currentBranch, branches: this.state.branches, tagsList: this.state.tagsList, branching: !disableBranching, commands: this.props.commands, pastCommits: this.state.pastCommits, model: this.props.model, nCommitsAhead: this.state.nCommitsAhead, nCommitsBehind: this.state.nCommitsBehind, repository: this.state.repository || '', trans: this.props.trans, submodules: this.state.submodules }));
}
/**
* Renders the main panel.
*
* @returns React element
*/
_renderMain() {
return (React.createElement(React.Fragment, null,
this._renderTabs(),
this.state.tab === 1 ? this._renderHistory() : this._renderChanges()));
}
/**
* Renders panel tabs.
*
* @returns React element
*/
_renderTabs() {
return (React.createElement(Tabs, { classes: {
root: tabsClass,
indicator: tabIndicatorClass
}, value: this.state.tab, onChange: this._onTabChange },
React.createElement(Tab, { classes: {
root: tabClass,
selected: selectedTabClass
}, title: this.props.trans.__('View changed files'), label: this.props.trans.__('Changes'), disableFocusRipple: true, disableRipple: true }),
React.createElement(Tab, { classes: {
root: tabClass,
selected: selectedTabClass
}, title: this.props.trans.__('View commit history'), label: this.props.trans.__('History'), disableFocusRipple: true, disableRipple: true })));
}
/**
* Renders a panel for viewing and committing file changes.
*
* @returns React element
*/
_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 }))));
}
/**
* Renders a panel for viewing commit history.
*
* @returns React element
*/
_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);
}
}
}