UNPKG

@jupyterlab/git

Version:

A JupyterLab extension for version control using git

1,062 lines 74.6 kB
import { Dialog, InputDialog, MainAreaWidget, Notification, ReactWidget, showDialog, showErrorMessage } from '@jupyterlab/apputils'; import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor'; import { PathExt, URLExt } from '@jupyterlab/coreutils'; import { Toolbar, ToolbarButton, closeIcon, saveIcon } from '@jupyterlab/ui-components'; import { ArrayExt, find } from '@lumino/algorithm'; import { PromiseDelegate } from '@lumino/coreutils'; import { Menu, Panel, Widget } from '@lumino/widgets'; import * as React from 'react'; import { CancelledError } from './cancelledError'; import { BranchPicker } from './components/BranchPicker'; import { CONTEXT_COMMANDS } from './components/FileList'; import { ManageRemoteDialogue } from './components/ManageRemoteDialogue'; import { NewTagDialogBox } from './components/NewTagDialog'; import { PreviewMainAreaWidget } from './components/diff/PreviewMainAreaWidget'; import { DiffModel } from './components/diff/model'; import { AUTH_ERROR_MESSAGES, requestAPI } from './git'; import { getDiffProvider } from './model'; import { showDetails, showError } from './notifications'; import { addIcon, diffIcon, discardIcon, gitIcon, historyIcon, openIcon, removeIcon, tagIcon } from './style/icons'; import { CommandIDs, ContextCommandIDs, Git } from './tokens'; import { AdvancedPushForm } from './widgets/AdvancedPushForm'; import { GitCredentialsForm } from './widgets/CredentialsBox'; import { CheckboxForm } from './widgets/GitResetToRemoteForm'; import { discardAllChanges } from './widgets/discardAllChanges'; /** * Git operations requiring authentication */ export var Operation; (function (Operation) { Operation["Clone"] = "Clone"; Operation["Pull"] = "Pull"; Operation["Push"] = "Push"; Operation["ForcePush"] = "ForcePush"; Operation["Fetch"] = "Fetch"; })(Operation || (Operation = {})); function pluralizedContextLabel(singular, plural) { return (args) => { const { files } = args; if (files.length > 1) { return plural; } else { return singular; } }; } /** * Add the commands for the git extension. */ export function addCommands(app, gitModel, editorFactory, fileBrowserModel, settings, translator) { const { commands, shell, serviceManager } = app; const { serverSettings } = serviceManager; const trans = translator.load('jupyterlab_git'); /** * Commit using a keystroke combination when in CommitBox. * * This command is not accessible from the user interface (not visible), * as it is handled by a signal listener in the CommitBox component instead. * The label and caption are given to ensure that the command will * show up in the shortcut editor UI with a nice description. */ commands.addCommand(CommandIDs.gitSubmitCommand, { label: trans.__('Commit from the Commit Box'), caption: trans.__('Submit the commit using the summary and description from commit box'), execute: () => void 0, isVisible: () => false }); /** * Add open terminal in the Git repository */ commands.addCommand(CommandIDs.gitTerminalCommand, { label: trans.__('Open Git Repository in Terminal'), caption: trans.__('Open a New Terminal to the Git Repository'), execute: async (args) => { const cwd = gitModel.pathRepository; const main = (await commands.execute('terminal:create-new', { ...args, cwd })); return main; }, isEnabled: () => gitModel.pathRepository !== null && app.serviceManager.terminals.isAvailable() }); /** Add open/go to git interface command */ commands.addCommand(CommandIDs.gitUI, { label: trans.__('Git Interface'), caption: trans.__('Go to Git user interface'), execute: () => { try { shell.activateById('jp-git-sessions'); } catch (_err) { console.error('Fail to open Git tab.'); } } }); /** Add git init command */ commands.addCommand(CommandIDs.gitInit, { label: trans.__('Initialize a Repository'), caption: trans.__('Create an empty Git repository or reinitialize an existing one'), execute: async () => { const currentPath = app.serviceManager.contents.localPath(fileBrowserModel.path); const result = await showDialog({ title: trans.__('Initialize a Repository'), body: trans.__('Do you really want to make this directory a Git Repo?'), buttons: [ Dialog.cancelButton({ label: trans.__('Cancel') }), Dialog.warnButton({ label: trans.__('Yes') }) ] }); if (result.button.accept) { const id = Notification.emit(trans.__('Initializing…'), 'in-progress', { autoClose: false }); try { await gitModel.init(currentPath); gitModel.pathRepository = currentPath; Notification.update({ id, message: trans.__('Git repository initialized.'), type: 'success', autoClose: 5000 }); } catch (error) { console.error(trans.__('Encountered an error when initializing the repository. Error: '), error); Notification.update({ id, message: trans.__('Failed to initialize the Git repository'), type: 'error', ...showError(error, trans) }); } } }, isEnabled: () => gitModel.pathRepository === null }); /** Open URL externally */ commands.addCommand(CommandIDs.gitOpenUrl, { label: args => trans.__(args['text']), execute: args => { const url = args['url']; window.open(url); } }); /** add toggle for simple staging */ commands.addCommand(CommandIDs.gitToggleSimpleStaging, { label: trans.__('Simple staging'), isToggled: () => !!settings.composite['simpleStaging'], execute: args => { settings.set('simpleStaging', !settings.composite['simpleStaging']); } }); /** Command to add a remote Git repository */ commands.addCommand(CommandIDs.gitManageRemote, { label: trans.__('Manage Remote Repositories'), caption: trans.__('Manage Remote Repositories'), isEnabled: () => gitModel.pathRepository !== null, execute: () => { if (gitModel.pathRepository === null) { console.warn(trans.__('Not in a Git repository. Unable to add a remote.')); return; } const widgetId = 'git-dialog-ManageRemote'; let anchor = document.querySelector(`#${widgetId}`); if (!anchor) { anchor = document.createElement('div'); anchor.id = widgetId; document.body.appendChild(anchor); } const dialog = ReactWidget.create(React.createElement(ManageRemoteDialogue, { trans: trans, model: gitModel, onClose: () => dialog.dispose() })); Widget.attach(dialog, anchor); } }); async function showGitignore(error) { const model = new CodeEditor.Model({}); const repoPath = gitModel.getRelativeFilePath(); const id = repoPath + '/.git-ignore'; const contentData = await gitModel.readGitIgnore(); const gitIgnoreWidget = find(shell.widgets(), shellWidget => shellWidget.id === id); if (gitIgnoreWidget) { shell.activateById(id); return; } model.sharedModel.setSource(contentData ? contentData : ''); const editor = new CodeEditorWrapper({ factory: editorFactory.newDocumentEditor.bind(editorFactory), model: model }); const modelChangedSignal = model.sharedModel.changed; editor.disposed.connect(() => { model.dispose(); }); const preview = new MainAreaWidget({ content: editor }); preview.title.label = '.gitignore'; preview.id = id; preview.title.icon = gitIcon; preview.title.closable = true; preview.title.caption = repoPath + '/.gitignore'; const saveButton = new ToolbarButton({ icon: saveIcon, onClick: async () => { if (saved) { return; } const newContent = model.sharedModel.getSource(); try { await gitModel.writeGitIgnore(newContent); preview.title.className = ''; saved = true; } catch (_error) { console.log('Could not save .gitignore'); } }, tooltip: trans.__('Saves .gitignore') }); let saved = true; preview.toolbar.addItem('save', saveButton); shell.add(preview); modelChangedSignal.connect(() => { if (saved) { saved = false; preview.title.className = 'not-saved'; } }); } /* Helper: Show gitignore hidden file */ async function showGitignoreHiddenFile(error, hidePrompt) { if (hidePrompt) { return showGitignore(error); } const result = await showDialog({ title: trans.__('Warning: The .gitignore file is a hidden file.'), body: (React.createElement("div", null, trans.__('Hidden files by default cannot be accessed with the regular code editor. In order to open the .gitignore file you must:'), React.createElement("ol", null, React.createElement("li", null, trans.__('Print the command below to create a jupyter_server_config.py file with defaults commented out. If you already have the file located in .jupyter, skip this step.'), React.createElement("div", { style: { padding: '0.5rem' } }, 'jupyter server --generate-config')), React.createElement("li", null, trans.__('Open jupyter_server_config.py, uncomment out the following line and set it to True:'), React.createElement("div", { style: { padding: '0.5rem' } }, 'c.ContentsManager.allow_hidden = False'))))), buttons: [ Dialog.cancelButton({ label: trans.__('Cancel') }), Dialog.okButton({ label: trans.__('Show .gitignore file anyways') }) ], checkbox: { label: trans.__('Do not show this warning again'), checked: false } }); if (result.button.accept) { settings.set('hideHiddenFileWarning', result.isChecked); showGitignore(error); } } /** Add git open gitignore command */ commands.addCommand(CommandIDs.gitOpenGitignore, { label: trans.__('Open .gitignore'), caption: trans.__('Open .gitignore'), isEnabled: () => gitModel.pathRepository !== null, execute: async () => { try { await gitModel.ensureGitignore(); } catch (error) { if ((error === null || error === void 0 ? void 0 : error.name) === 'hiddenFile') { await showGitignoreHiddenFile(error, settings.composite['hideHiddenFileWarning']); } } } }); /** Add git push command */ commands.addCommand(CommandIDs.gitPush, { label: args => args['advanced'] ? trans.__('Push to Remote (Advanced)') : trans.__('Push to Remote'), caption: trans.__('Push code to remote repository'), isEnabled: () => gitModel.pathRepository !== null, execute: async (args) => { let id = null; try { let remote; let force; if (args['advanced']) { const result = await showDialog({ title: trans.__('Please select push options.'), body: new AdvancedPushForm(trans, gitModel), buttons: [ Dialog.cancelButton({ label: trans.__('Cancel') }), Dialog.okButton({ label: trans.__('Proceed') }) ] }); if (result.button.accept && result.value) { remote = result.value.remoteName; force = result.value.force; } else { return; } } id = Notification.emit(trans.__('Pushing…'), 'in-progress', { autoClose: false }); const details = await showGitOperationDialog(gitModel, force ? Operation.ForcePush : Operation.Push, trans, (args = { remote })); Notification.update({ id, message: trans.__('Successfully pushed'), type: 'success', ...showDetails(details, trans) }); } catch (error) { if (error.name !== 'CancelledError') { console.error(trans.__('Encountered an error when pushing changes. Error: '), error); const message = trans.__('Failed to push'); const options = showError(error, trans); if (id) { Notification.update({ id, message, type: 'error', ...options }); } else { Notification.error(message, options); } } else { if (id) { Notification.dismiss(id); } } } } }); /** Add git pull command */ commands.addCommand(CommandIDs.gitPull, { label: args => args.force ? trans.__('Pull from Remote (Force)') : trans.__('Pull from Remote'), caption: args => args.force ? trans.__('Discard all current changes and pull from remote repository') : trans.__('Pull latest code from remote repository'), isEnabled: () => gitModel.pathRepository !== null, execute: async (args) => { let id = null; try { if (args.force) { await discardAllChanges(gitModel, trans, args.fallback); } id = Notification.emit(trans.__('Pulling…'), 'in-progress', { autoClose: false }); const details = await showGitOperationDialog(gitModel, Operation.Pull, trans); Notification.update({ id, message: trans.__('Successfully pulled'), type: 'success', ...showDetails(details, trans) }); } catch (error) { if (error.name !== 'CancelledError') { console.error('Encountered an error when pulling changes. Error: ', error); const errorMsg = typeof error === 'string' ? error : error.message; // Discard changes then retry pull if (errorMsg .toLowerCase() .includes('your local changes to the following files would be overwritten by merge')) { await commands.execute(CommandIDs.gitPull, { force: true, fallback: true }); } else { if (error.cancelled) { if (id) { Notification.dismiss(id); } } else { const message = trans.__('Failed to pull'); const options = showError(error, trans); if (id) { Notification.update({ id, message, ...options }); } else { Notification.error(message, options); } } } } else { if (id) { Notification.dismiss(id); } } } } }); /** Add git reset --hard <remote-tracking-branch> command */ commands.addCommand(CommandIDs.gitResetToRemote, { label: trans.__('Reset to Remote'), caption: trans.__('Reset Current Branch to Remote State'), isEnabled: () => gitModel.pathRepository !== null, execute: async () => { var _a, _b; const result = await showDialog({ title: trans.__('Reset to Remote'), body: new CheckboxForm(trans.__('To bring the current branch to the state of its corresponding remote tracking branch, \ a hard reset will be performed, which may result in some files being permanently deleted \ and some changes being permanently discarded. Are you sure you want to proceed? \ This action cannot be undone.'), trans.__('Close all opened files to avoid conflicts')), buttons: [ Dialog.cancelButton({ label: trans.__('Cancel') }), Dialog.warnButton({ label: trans.__('Proceed') }) ] }); if (result.button.accept) { let id = null; try { if ((_a = result.value) === null || _a === void 0 ? void 0 : _a.checked) { id = Notification.emit(trans.__('Closing all opened files...'), 'in-progress'); await fileBrowserModel.manager.closeAll(); } const message = trans.__('Resetting...'); if (id) { Notification.update({ id, message }); } else { id = Notification.emit(message, 'in-progress', { autoClose: false }); } await gitModel.resetToCommit((_b = gitModel.status.remote) !== null && _b !== void 0 ? _b : undefined); Notification.update({ id, message: trans.__('Successfully reset'), type: 'success', ...showDetails(trans.__('Successfully reset the current branch to its remote state'), trans) }); } catch (error) { console.error('Encountered an error when resetting the current branch to its remote state. Error: ', error); const message = trans.__('Reset failed'); const options = showError(error, trans); if (id) { Notification.update({ id, type: 'error', message, ...options }); } else { Notification.error(message, options); } } } } }); /** * Git display diff command - internal command * * @params model: The diff model to display * @params isText: Optional, whether the content is a plain text * @params isMerge: Optional, whether the diff is a merge conflict * @returns the main area widget or null */ commands.addCommand(CommandIDs.gitShowDiff, { label: trans.__('Show Diff'), caption: trans.__('Display a file diff.'), execute: async (args) => { var _a; const { model, isText, isPreview } = args; const fullPath = PathExt.join((_a = model.repositoryPath) !== null && _a !== void 0 ? _a : '/', model.filename); const buildDiffWidget = getDiffProvider(fullPath, isText); if (buildDiffWidget) { const id = `git-diff-${fullPath}-${model.reference.label}-${model.challenger.label}`; const mainAreaItems = shell.widgets('main'); let mainAreaItem = null; for (const item of mainAreaItems) { if (item.id === id) { shell.activateById(id); mainAreaItem = item; break; } } if (!mainAreaItem) { const content = new Panel(); const modelIsLoading = new PromiseDelegate(); const diffWidget = (mainAreaItem = new PreviewMainAreaWidget({ content, reveal: modelIsLoading.promise, isPreview })); diffWidget.id = id; diffWidget.title.label = PathExt.basename(model.filename); diffWidget.title.caption = fullPath; diffWidget.title.icon = diffIcon; diffWidget.title.closable = true; diffWidget.title.className = 'jp-git-diff-title'; diffWidget.addClass('jp-git-diff-parent-widget'); shell.add(diffWidget, 'main'); shell.activateById(diffWidget.id); // Search for the tab const dockPanel = app.shell._dockPanel; // Get the index of the most recent tab opened let tabPosition = -1; const tabBar = Array.from(dockPanel.tabBars()).find(bar => { tabPosition = bar.titles.indexOf(diffWidget.title); return tabPosition !== -1; }); // Pin the preview screen if applicable if (tabBar) { PreviewMainAreaWidget.pinWidget(tabPosition, tabBar, diffWidget); } // Create the diff widget try { const widget = await buildDiffWidget({ model, toolbar: diffWidget.toolbar, translator, serverSettings }); diffWidget.toolbar.addItem('spacer', Toolbar.createSpacerItem()); // Do not allow the user to refresh during merge conflicts if (model.hasConflict) { const resolveButton = new ToolbarButton({ label: trans.__('Mark as resolved'), onClick: async () => { var _a; if (!widget.isFileResolved) { const result = await showDialog({ title: trans.__('Resolve with conflicts'), body: trans.__('Are you sure you want to mark this file as resolved with merge conflicts?') }); // Bail early if the user wants to finish resolving conflicts if (!result.button.accept) { return; } } try { await serviceManager.contents.save(fullPath, await widget.getResolvedFile()); await gitModel.add(model.filename); await gitModel.refresh(); } catch (reason) { Notification.error((_a = reason.message) !== null && _a !== void 0 ? _a : reason); } finally { diffWidget.dispose(); } }, tooltip: trans.__('Mark file as resolved'), className: 'jp-git-diff-resolve' }); diffWidget.toolbar.addItem('resolve', resolveButton); } else { const refreshButton = new ToolbarButton({ label: trans.__('Refresh'), onClick: async () => { await widget.refresh(); refreshButton.hide(); }, tooltip: trans.__('Refresh diff widget'), className: 'jp-git-diff-refresh' }); refreshButton.hide(); diffWidget.toolbar.addItem('refresh', refreshButton); const refresh = () => { refreshButton.show(); }; model.changed.connect(refresh); widget.disposed.connect(() => model.changed.disconnect(refresh)); } // Load the diff widget modelIsLoading.resolve(); content.addWidget(widget); } catch (reason) { console.error(reason); const msg = `Load Diff Model Error (${reason.message || reason})`; modelIsLoading.reject(msg); } if (model.challenger.source === Git.Diff.SpecialRef.INDEX || model.challenger.source === Git.Diff.SpecialRef.WORKING || model.reference.source === Git.Diff.SpecialRef.INDEX || model.reference.source === Git.Diff.SpecialRef.WORKING) { const maybeClose = (_, status) => { const targetFile = status.files.find(fileStatus => model.filename === fileStatus.from); if (!targetFile || targetFile.status === 'unmodified') { gitModel.statusChanged.disconnect(maybeClose); mainAreaItem.dispose(); } }; gitModel.statusChanged.connect(maybeClose); } } return mainAreaItem; } else { await showErrorMessage(trans.__('Diff Not Supported'), trans.__('Diff is not supported for %1 files.', PathExt.extname(model.filename).toLocaleLowerCase())); return null; } }, icon: diffIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(CommandIDs.gitMerge, { label: trans.__('Merge Branch…'), caption: trans.__('Merge selected branch in the current branch'), execute: async (args) => { var _a, _b, _c, _d, _e; let { branch } = args !== null && args !== void 0 ? args : {}; if (!branch) { // Prompts user to pick a branch const localBranches = gitModel.branches.filter(branch => !branch.is_current_branch && !branch.is_remote_branch); const widgetId = 'git-dialog-MergeBranch'; let anchor = document.querySelector(`#${widgetId}`); if (!anchor) { anchor = document.createElement('div'); anchor.id = widgetId; document.body.appendChild(anchor); } const waitForDialog = new PromiseDelegate(); const dialog = ReactWidget.create(React.createElement(BranchPicker, { action: "merge", currentBranch: (_b = (_a = gitModel.currentBranch) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : '', branches: localBranches, onClose: (branch) => { dialog.dispose(); waitForDialog.resolve(branch !== null && branch !== void 0 ? branch : null); }, trans: trans })); Widget.attach(dialog, anchor); branch = (_c = (await waitForDialog.promise)) !== null && _c !== void 0 ? _c : undefined; } if (branch) { const id = Notification.emit(trans.__("Merging branch '%1'…", branch), 'in-progress'); try { await gitModel.merge(branch); } catch (err) { Notification.update({ id, type: 'error', message: trans.__("Failed to merge branch '%1' into '%2'.", branch, (_d = gitModel.currentBranch) === null || _d === void 0 ? void 0 : _d.name), ...showError(err, trans) }); return; } Notification.update({ id, type: 'success', message: trans.__("Branch '%1' merged into '%2'.", branch, (_e = gitModel.currentBranch) === null || _e === void 0 ? void 0 : _e.name) }); } }, isEnabled: () => gitModel.branches.some(branch => !branch.is_current_branch && !branch.is_remote_branch) }); commands.addCommand(CommandIDs.gitRebase, { label: trans.__('Rebase branch…'), caption: trans.__('Rebase current branch onto the selected branch'), execute: async (args) => { var _a, _b, _c, _d, _e; let { branch } = args !== null && args !== void 0 ? args : {}; if (!branch) { // Prompts user to pick a branch const localBranches = gitModel.branches.filter(branch => !branch.is_current_branch && !branch.is_remote_branch); const widgetId = 'git-dialog-MergeBranch'; let anchor = document.querySelector(`#${widgetId}`); if (!anchor) { anchor = document.createElement('div'); anchor.id = widgetId; document.body.appendChild(anchor); } const waitForDialog = new PromiseDelegate(); const dialog = ReactWidget.create(React.createElement(BranchPicker, { action: "rebase", currentBranch: (_b = (_a = gitModel.currentBranch) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : '', branches: localBranches, onClose: (branch) => { dialog.dispose(); waitForDialog.resolve(branch !== null && branch !== void 0 ? branch : null); }, trans: trans })); Widget.attach(dialog, anchor); branch = (_c = (await waitForDialog.promise)) !== null && _c !== void 0 ? _c : undefined; } if (branch) { const id = Notification.emit(trans.__("Rebasing current branch onto '%1'…", branch), 'in-progress'); try { await gitModel.rebase(branch); } catch (err) { Notification.update({ id, type: 'error', message: trans.__("Failed to rebase branch '%1' onto '%2'.", (_d = gitModel.currentBranch) === null || _d === void 0 ? void 0 : _d.name, branch), ...showError(err, trans) }); return; } Notification.update({ id, type: 'success', message: trans.__("Branch '%1' rebase onto '%2'.", (_e = gitModel.currentBranch) === null || _e === void 0 ? void 0 : _e.name, branch) }); } }, isEnabled: () => gitModel.branches.some(branch => !branch.is_current_branch && !branch.is_remote_branch) }); commands.addCommand(CommandIDs.gitResolveRebase, { label: (args = {}) => { switch (args.action) { case 'continue': return trans.__('Continue rebase'); case 'skip': return trans.__('Skip current commit'); case 'abort': return trans.__('Abort rebase'); default: return trans.__('Resolve rebase'); } }, caption: (args = {}) => { switch (args.action) { case 'continue': return trans.__('Continue the rebase by committing the current state.'); case 'skip': return trans.__('Skip current commit and continue the rebase.'); case 'abort': return trans.__('Abort the rebase'); default: return trans.__('Resolve rebase'); } }, execute: async (args = {}) => { var _a, _b, _c; const { action } = args; if (['continue', 'abort', 'skip'].includes(action !== null && action !== void 0 ? action : '')) { const message = (_a = (action => { switch (action) { case 'continue': return trans.__('Continue the rebase…'); case 'skip': return trans.__('Skip current commit…'); case 'abort': return trans.__('Abort the rebase…'); } })(action)) !== null && _a !== void 0 ? _a : ''; const id = Notification.emit(message, 'in-progress', { autoClose: false }); try { await gitModel.resolveRebase(action); } catch (err) { const message = (_b = (action => { switch (action) { case 'continue': return trans.__('Fail to continue rebasing.'); case 'skip': return trans.__('Fail to skip current commit when rebasing.'); case 'abort': return trans.__('Fail to abort the rebase.'); } })(action)) !== null && _b !== void 0 ? _b : ''; Notification.update({ id, type: 'error', message, ...showError(err, trans) }); return; } const message_ = (_c = (action => { switch (action) { case 'continue': return trans.__('Commit submitted continuing rebase.'); case 'skip': return trans.__('Current commit skipped.'); case 'abort': return trans.__('Rebase aborted.'); } })(action)) !== null && _c !== void 0 ? _c : ''; Notification.update({ id, type: 'success', message: message_, autoClose: 5000 }); } }, isEnabled: () => gitModel.status.state === Git.State.REBASING }); commands.addCommand(CommandIDs.gitStash, { label: trans.__('Stash Changes'), caption: trans.__('Stash all current changes'), isEnabled: () => gitModel.pathRepository !== null, execute: async (args) => { var _a; const stashDialog = await InputDialog.getText({ // Default stash message is the last commit hash and message title: trans.__('Do you want to stash your changes? '), placeholder: trans.__('Stash message (optional)'), okLabel: trans.__('Stash') }); const stashMsg = (_a = stashDialog.value) !== null && _a !== void 0 ? _a : ''; if (stashDialog.button.accept) { const id = Notification.emit(trans.__('Stashing changes'), 'in-progress', { autoClose: false }); try { await gitModel.stashChanges(stashMsg); // Success Notification.update({ id, message: trans.__('Successfully stashed'), type: 'success', autoClose: 5000 }); } catch (error) { console.error('Encountered an error when pulling changes. Error: ', error); Notification.update({ id, message: trans.__('Failed to stash'), type: 'error', ...showError(error, trans) }); } } } }); /** * Calls refreshStash * */ commands.addCommand(CommandIDs.gitStashList, { label: trans.__('Stash List'), caption: trans.__('Get all the stashed changes'), // Check if we are in a git repository isEnabled: () => gitModel.pathRepository !== null, execute: async (args) => { try { await gitModel.refreshStash(); Notification.info(trans.__('Got the stash list')); } catch (err) { Notification.error(trans.__('Failed to get the stash'), showError(err, trans)); } } }); /* Context menu commands */ commands.addCommand(ContextCommandIDs.gitFileOpen, { label: trans.__('Open'), caption: pluralizedContextLabel(trans.__('Open selected file'), trans.__('Open selected files')), execute: async (args) => { const { files } = args; for (const file of files) { const { x, y, to } = file; if (x === 'D' || y === 'D') { await showErrorMessage(trans.__('Open File Failed'), trans.__('This file has been deleted!')); return; } try { if (to[to.length - 1] !== '/') { commands.execute('docmanager:open', { path: gitModel.getRelativeFilePath(to) }); } else { console.log('Cannot open a folder here'); } } catch (_err) { console.error(`Fail to open ${to}.`); } } }, icon: openIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.openFileFromDiff, { label: trans.__('Open File'), caption: trans.__('Open file from its diff view'), execute: async (_) => { const domNode = app.contextMenuHitTest((node) => { const nodeId = node.dataset.id; return (nodeId === null || nodeId === void 0 ? void 0 : nodeId.substring(0, 8)) === 'git-diff'; }); if (!domNode) { return; } const matches = Array.from(shell.widgets('main')).filter(widget => widget.id === domNode.dataset.id); if (matches.length === 0) { return; } const diffModel = matches[0].content .widgets[0].model; const filename = diffModel.filename; if (diffModel.reference.source === Git.Diff.SpecialRef.INDEX || diffModel.reference.source === Git.Diff.SpecialRef.WORKING || diffModel.challenger.source === Git.Diff.SpecialRef.INDEX || diffModel.challenger.source === Git.Diff.SpecialRef.WORKING) { const file = gitModel.status.files.find(fileStatus => fileStatus.from === filename); if (file) { commands.execute(ContextCommandIDs.gitFileOpen, { files: [file] }); } } else { commands.execute('docmanager:open', { path: gitModel.getRelativeFilePath(filename) }); } } }); commands.addCommand(ContextCommandIDs.gitFileDiff, { label: trans.__('Diff'), caption: pluralizedContextLabel(trans.__('Diff selected file'), trans.__('Diff selected files')), execute: async (args) => { const { files } = args; if (gitModel.pathRepository === null) { return; } for (const file of files) { const { context, filePath, previousFilePath, isText, status, isPreview } = file; // nothing to compare to for untracked files if (status === 'untracked') { continue; } const repositoryPath = gitModel.pathRepository; const filename = filePath; const fullPath = PathExt.join(repositoryPath, filename); const diffContext = { currentRef: '', previousRef: 'HEAD', ...context }; if (status === 'unmerged') { diffContext.baseRef = Git.Diff.SpecialRef.BASE; diffContext.currentRef = gitModel.status.state !== Git.State.MERGING ? gitModel.status.state === Git.State.REBASING ? 'REBASE_HEAD' : 'CHERRY_PICK_HEAD' : 'MERGE_HEAD'; } else if (!diffContext.currentRef) { diffContext.currentRef = status === 'staged' ? Git.Diff.SpecialRef.INDEX : Git.Diff.SpecialRef.WORKING; } const challengerRef = Git.Diff.SpecialRef[diffContext.currentRef] ? { special: Git.Diff.SpecialRef[diffContext.currentRef] } : { git: diffContext.currentRef }; // Base props used for Diff Model const props = { challenger: { content: async () => { return requestAPI(URLExt.join(repositoryPath, 'content'), 'POST', { filename, // @ts-expect-error this is serializable reference: challengerRef }, 'git', serverSettings).then(data => data.content); }, label: Git.Diff.SpecialRef[diffContext.currentRef] || diffContext.currentRef, source: diffContext.currentRef, updateAt: Date.now() }, filename, reference: { content: async () => { return requestAPI(URLExt.join(repositoryPath, 'content'), 'POST', { filename: previousFilePath !== null && previousFilePath !== void 0 ? previousFilePath : filename, reference: { git: diffContext.previousRef } }, 'git', serverSettings).then(data => data.content); }, label: Git.Diff.SpecialRef[diffContext.previousRef] || diffContext.previousRef, source: diffContext.previousRef, updateAt: Date.now() }, repositoryPath }; // Case when file is relocated if (previousFilePath) { props.reference.label = `${previousFilePath} (${props.reference.label.slice(0, 7)})`; props.challenger.label = `${filePath} (${props.challenger.label.slice(0, 7)})`; } if (diffContext.baseRef) { props.reference.label = trans.__('Current'); props.challenger.label = trans.__('Incoming'); // Only add base when diff-ing merge conflicts props.base = { content: async () => { return requestAPI(URLExt.join(repositoryPath, 'content'), 'POST', { filename, reference: { special: Git.Diff.SpecialRef[diffContext.baseRef] } }, 'git', serverSettings).then(data => data.content); }, label: trans.__('Result'), source: diffContext.baseRef, updateAt: Date.now() }; } // Create the diff widget const model = new DiffModel(props); const widget = await commands.execute(CommandIDs.gitShowDiff, { model, isText, isPreview }); if (widget) { // Trigger diff model update if (diffContext.previousRef === 'HEAD') { const updateHead = () => { model.reference = { ...model.reference, updateAt: Date.now() }; }; gitModel.headChanged.connect(updateHead); widget.disposed.connect(() => { gitModel.headChanged.disconnect(updateHead); }); } // If the diff is on the current file and it is updated => diff model changed if (diffContext.currentRef === Git.Diff.SpecialRef.WORKING) { const updateCurrent = (m, change) => { var _a, _b, _c, _d; const updateAt = new Date((_b = (_a = change.newValue) === null || _a === void 0 ? void 0 : _a.last_modified) !== null && _b !== void 0 ? _b : 0).valueOf(); if (app.serviceManager.contents.localPath((_d = (_c = change.newValue) === null || _c === void 0 ? void 0 : _c.path) !== null && _d !== void 0 ? _d : '') === fullPath && model.challenger.updateAt !== updateAt) { model.challenger = { ...model.challenger, updateAt }; } }; // More robust than fileBrowser.model.fileChanged app.serviceManager.contents.fileChanged.connect(updateCurrent); widget.disposed.connect(() => { app.serviceManager.contents.fileChanged.disconnect(updateCurrent); }); } } } }, icon: diffIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitFileAdd, { label: trans.__('Add'), caption: pluralizedContextLabel(trans.__('Stage or track the changes to selected file'), trans.__('Stage or track the changes of selected files')), execute: async (args) => { const { files } = args; for (const