UNPKG

@jupyterlab/git

Version:

A JupyterLab extension for version control using git

667 lines (666 loc) 31.5 kB
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); } } }