@jupyterlab/git
Version:
A JupyterLab extension for version control using git
288 lines (287 loc) • 12.7 kB
JavaScript
import { Dialog, Notification, showDialog, showErrorMessage } from '@jupyterlab/apputils';
import ClearIcon from '@mui/icons-material/Clear';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import * as React from 'react';
import { FixedSizeList } from 'react-window';
import { classes } from 'typestyle';
import { showError } from '../notifications';
import { hiddenButtonStyle } from '../style/ActionButtonStyle';
import { activeListItemClass, filterClass, filterClearClass, filterInputClass, filterWrapperClass, listItemClass, listItemIconClass, nameClass, newBranchButtonClass, wrapperClass } from '../style/BranchMenu';
import { branchIcon, mergeIcon, trashIcon } from '../style/icons';
import { CommandIDs, Git } from '../tokens';
import { ActionButton } from './ActionButton';
import { NewBranchDialog } from './NewBranchDialog';
const ITEM_HEIGHT = 24.8; // HTML element height for a single branch
const MIN_HEIGHT = 150; // Minimal HTML element height for the branches list
const MAX_HEIGHT = 400; // Maximal HTML element height for the branches list
/**
* Callback invoked upon encountering an error when switching branches.
*
* @private
* @param error - error
* @param id - notification id
* @param trans - translation object
*/
function onBranchError(error, id, trans) {
if (error.message.includes('following files would be overwritten')) {
Notification.dismiss(id);
showDialog({
title: trans.__('Unable to switch branch'),
body: (React.createElement(React.Fragment, null,
React.createElement("p", null, trans.__('Your changes to the following files would be overwritten by switching:')),
React.createElement(List, null, error.message.split('\n').slice(1, -3).map(renderFileName)),
React.createElement("span", null, trans.__('Please commit, stash, or discard your changes before you switch branches.')))),
buttons: [Dialog.okButton({ label: trans.__('Dismiss') })]
});
}
else {
Notification.update({
id,
message: trans.__('Failed to switch branch.'),
type: 'error',
...showError(error, trans)
});
}
}
/**
* Renders a file name.
*
* @private
* @param filename - file name
* @returns React element
*/
function renderFileName(filename) {
return React.createElement(ListItem, { key: filename }, filename);
}
/**
* React component for rendering a branch menu.
*/
export class BranchMenu extends React.Component {
/**
* Returns a React component for rendering a branch menu.
*
* @param props - component properties
* @returns React component
*/
constructor(props) {
super(props);
this.CHANGES_ERR_MSG = this.props.trans.__('The current branch contains files with uncommitted changes. Please commit or discard these changes before switching to or creating another branch.');
/**
* Renders a menu item.
*
* @param props Row properties
* @returns React element
*/
this._renderItem = (props) => {
const { data, index, style } = props;
const branch = data[index];
const isActive = branch.is_current_branch;
return (React.createElement(ListItem, { button: true, title: !isActive
? this.props.trans.__('Switch to branch: %1', branch.name)
: '', className: classes(listItemClass, isActive ? activeListItemClass : null), onClick: this._onBranchClickFactory(branch.name, branch.is_remote_branch), role: "listitem", style: style },
React.createElement(branchIcon.react, { className: listItemIconClass, tag: "span" }),
React.createElement("span", { className: nameClass }, branch.name),
!branch.is_remote_branch && !isActive && (React.createElement(React.Fragment, null,
React.createElement(ActionButton, { className: hiddenButtonStyle, icon: trashIcon, title: this.props.trans.__('Delete this branch locally'), onClick: async (event) => {
event === null || event === void 0 ? void 0 : event.stopPropagation();
await this._onDeleteBranch(branch.name);
} }),
React.createElement(ActionButton, { className: hiddenButtonStyle, icon: mergeIcon, title: this.props.trans.__('Merge this branch into the current one'), onClick: (event) => {
event === null || event === void 0 ? void 0 : event.stopPropagation();
this._onMergeBranch(branch.name);
} })))));
};
/**
* Callback invoked upon a change to the menu filter.
*
* @param event - event object
*/
this._onFilterChange = (event) => {
this.setState({
filter: event.target.value
});
};
/**
* Callback invoked to reset the menu filter.
*/
this._resetFilter = () => {
this.setState({
filter: ''
});
};
/**
* Callback on delete branch name button
*
* @param branchName Branch name
*/
this._onDeleteBranch = async (branchName) => {
const acknowledgement = await showDialog({
title: this.props.trans.__('Delete branch'),
body: (React.createElement("p", null,
this.props.trans.__('Are you sure you want to permanently delete the branch '),
React.createElement("b", null, branchName),
"?",
React.createElement("br", null),
this.props.trans.__('This action cannot be undone.'))),
buttons: [
Dialog.cancelButton({ label: this.props.trans.__('Cancel') }),
Dialog.warnButton({ label: this.props.trans.__('Delete') })
]
});
if (acknowledgement.button.accept) {
try {
await this.props.model.deleteBranch(branchName);
await this.props.model.refreshBranch();
}
catch (error) {
console.error(`Failed to delete branch ${branchName}`, error);
}
}
};
/**
* Callback on merge branch name button
*
* @param branchName Branch name
*/
this._onMergeBranch = async (branch) => {
await this.props.commands.execute(CommandIDs.gitMerge, { branch });
};
/**
* Callback invoked upon clicking a button to create a new branch.
*
* @param event - event object
*/
this._onNewBranchClick = () => {
if (!this.props.branching) {
showErrorMessage(this.props.trans.__('Creating a new branch is disabled'), this.CHANGES_ERR_MSG);
return;
}
this.setState({
branchDialog: true
});
};
/**
* Callback invoked upon closing a dialog to create a new branch.
*/
this._onNewBranchDialogClose = () => {
this.setState({
branchDialog: false
});
};
this.state = {
filter: '',
branchDialog: false
};
}
/**
* Renders the component.
*
* @returns React element
*/
render() {
return (React.createElement("div", { className: wrapperClass },
this._renderFilter(),
this._renderBranchList(),
this._renderNewBranchDialog()));
}
/**
* Renders a branch input filter.
*
* @returns React element
*/
_renderFilter() {
return (React.createElement("div", { className: filterWrapperClass },
React.createElement("div", { className: filterClass },
React.createElement("input", { className: filterInputClass, type: "text", onChange: this._onFilterChange, value: this.state.filter, placeholder: this.props.trans.__('Filter'), title: this.props.trans.__('Filter branch menu') }),
this.state.filter ? (React.createElement("button", { className: filterClearClass },
React.createElement(ClearIcon, { titleAccess: this.props.trans.__('Clear the current filter'), fontSize: "small", onClick: this._resetFilter }))) : null),
React.createElement("input", { className: newBranchButtonClass, type: "button", title: this.props.trans.__('Create a new branch'), value: this.props.trans.__('New Branch'), onClick: this._onNewBranchClick })));
}
/**
* Renders a
*
* @returns React element
*/
_renderBranchList() {
// Perform a "simple" filter... (TODO: consider implementing fuzzy filtering)
const filter = this.state.filter;
const branches = this.props.branches.filter(branch => {
// Don't include "current branch" is the repository is not in default state
if (this.props.model.status.state !== Git.State.DEFAULT &&
branch.is_current_branch) {
return false;
}
return !filter || branch.name.includes(filter);
});
return (React.createElement(FixedSizeList, { height: Math.min(Math.max(MIN_HEIGHT, branches.length * ITEM_HEIGHT), MAX_HEIGHT), itemCount: branches.length, itemData: branches, itemKey: (index, data) => data[index].name, itemSize: ITEM_HEIGHT, style: { overflowX: 'hidden', paddingTop: 0, paddingBottom: 0 }, width: 'auto' }, this._renderItem));
}
/**
* Renders a dialog for creating a new branch.
*
* @returns React element
*/
_renderNewBranchDialog() {
return (React.createElement(NewBranchDialog, { currentBranch: this.props.currentBranch, branches: this.props.branches, open: this.state.branchDialog, model: this.props.model, onClose: this._onNewBranchDialogClose, trans: this.props.trans }));
}
/**
* Returns a callback which is invoked upon clicking a branch name.
*
* @param branch - branch name
* @returns callback
*/
_onBranchClickFactory(branch, isRemote) {
/**
* Callback invoked upon clicking a branch name.
*
* @private
* @param event - event object
* @returns promise which resolves upon attempting to switch branches
*/
const onClick = async () => {
if (!this.props.branching) {
showErrorMessage(this.props.trans.__('Switching branches is disabled'), this.CHANGES_ERR_MSG);
return;
}
let message = 'Switched branch.';
let type = 'success';
const opts = {
branchname: branch
};
const id = Notification.emit(this.props.trans.__('Switching branch…'), 'in-progress', { autoClose: false });
try {
let shouldCheckout = true;
const localBranchname = branch.split('/').slice(-1)[0];
const localBranchExists = this.props.model.branches.find(branch => branch.name === localBranchname);
if (localBranchExists && isRemote) {
const result = await showDialog({
title: this.props.trans.__('Confirm Checkout'),
body: this.props.trans.__('Checking out this branch will reset your local copy to the remote. Any changes not pushed to the remote will be lost. Are you sure you want to continue?'),
buttons: [
Dialog.cancelButton({ label: this.props.trans.__('Cancel') }),
Dialog.warnButton({ label: this.props.trans.__('Continue') })
]
});
if (!result.button.accept) {
shouldCheckout = false;
message = 'Checkout cancelled.';
type = 'warning';
}
}
if (shouldCheckout) {
await this.props.model.checkout(opts);
}
}
catch (err) {
return onBranchError(err, id, this.props.trans);
}
Notification.update({
id,
message: this.props.trans.__(message),
type,
autoClose: 5000
});
};
return onClick;
}
}