diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ca8bce00..6766ccf23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ jobs: run: | jlpm jlpm run eslint:check - python -m pip install -e .[dev,tests] + python -m pip install -e .[tests] # Python formatting checks black . --check @@ -82,8 +82,6 @@ jobs: jupyter labextension list 2>&1 | grep -ie "@jupyterlab/git.*OK" python -m jupyterlab.browser_check - # Run our extension-specific browser integration test - # python tests/test-browser/run_browser_test.py - uses: actions/upload-artifact@v2 if: matrix.python-version == '3.9' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a786f412..bae9acb8e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,13 +22,13 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install jupyter_packaging~=0.7.9 jupyterlab~=3.0 packaging setuptools twine wheel + pip install jupyter_packaging~=0.7.9 jupyterlab~=3.0 packaging setuptools twine build - name: Publish the Python package env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | - python setup.py sdist bdist_wheel + python -m build twine upload dist/* - name: Publish the NPM package run: | diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 013b6a60b..0f30005c8 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -14,8 +14,8 @@ import pexpect import tornado import tornado.locks -from nbdime import diff_notebooks, merge_notebooks from jupyter_server.utils import ensure_async +from nbdime import diff_notebooks, merge_notebooks from .log import get_logger @@ -1219,11 +1219,41 @@ async def _get_tag(self, path, commit_sha): ) ) - async def show(self, filename, ref, path): + async def _get_base_ref(self, path, filename): + """Get the object reference for an unmerged ``filename`` at base stage. + + Execute git ls-files -u -z + + Returns: + The object reference or None + """ + command = ["git", "ls-files", "-u", "-z", filename] + + code, output, error = await execute(command, cwd=path) + if code != 0: + raise subprocess.CalledProcessError( + code, " ".join(command), output=output, stderr=error + ) + + split_line = strip_and_split(output)[0].split() + + return split_line[1] if len(split_line) > 1 else None + + async def show(self, path, ref, filename=None): """ - Execute git show command & return the result. + Execute + git show + Or + git show + + Return the file content """ - command = ["git", "show", "{}:{}".format(ref, filename)] + command = ["git", "show"] + + if filename is None: + command.append(ref) + else: + command.append(f"{ref}:{filename}") code, output, error = await execute(command, cwd=path) @@ -1286,7 +1316,11 @@ async def get_content_at_reference( log_message="Error occurred while executing command to retrieve plaintext content as file is not UTF-8." ) - content = await self.show(filename, "", path) + content = await self.show(path, "", filename) + elif reference["special"] == "BASE": + # Special case of file in merge conflict for which we want the base (aka common ancestor) version + ref = await self._get_base_ref(path, filename) + content = await self.show(path, ref) else: raise tornado.web.HTTPError( log_message="Error while retrieving plaintext content, unknown special ref '{}'.".format( @@ -1300,7 +1334,7 @@ async def get_content_at_reference( log_message="Error occurred while executing command to retrieve plaintext content as file is not UTF-8." ) - content = await self.show(filename, reference["git"], path) + content = await self.show(path, reference["git"], filename) else: content = "" diff --git a/jupyterlab_git/tests/test_handlers.py b/jupyterlab_git/tests/test_handlers.py index ad90f4164..30f5e152d 100644 --- a/jupyterlab_git/tests/test_handlers.py +++ b/jupyterlab_git/tests/test_handlers.py @@ -723,6 +723,50 @@ async def test_content_index(mock_execute, jp_fetch, jp_root_dir): ) +@patch("jupyterlab_git.git.execute") +async def test_content_base(mock_execute, jp_fetch, jp_root_dir): + # Given + local_path = jp_root_dir / "test_path" + filename = "my/file" + content = "dummy content file\nwith multiple lines" + obj_ref = "915bb14609daab65e5304e59d89c626283ae49fc" + + mock_execute.side_effect = [ + maybe_future( + ( + 0, + "100644 {1} 1 {0}\x00100644 285bdbc14e499b85ec407512a3bb3992fa3d4082 2 {0}\x00100644 66ac842dfb0b5c20f757111d6b3edd56d80622b4 3 {0}\x00".format( + filename, obj_ref + ), + "", + ) + ), + maybe_future((0, content, "")), + ] + + # When + body = { + "filename": filename, + "reference": {"special": "BASE"}, + } + response = await jp_fetch( + NAMESPACE, local_path.name, "content", body=json.dumps(body), method="POST" + ) + + # Then + assert response.code == 200 + payload = json.loads(response.body) + assert payload["content"] == content + mock_execute.assert_has_calls( + [ + call( + ["git", "show", obj_ref], + cwd=str(local_path), + ), + ], + ) + + @patch("jupyterlab_git.git.execute") async def test_content_unknown_special(mock_execute, jp_fetch, jp_root_dir): # Given diff --git a/package.json b/package.json index 383e33832..842d7d25a 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ "license": "BSD-3-Clause", "author": "Jupyter Development Team", "keywords": [ - "jupyter", - "jupyterlab", - "jupyterlab-extension" + "Jupyter", + "JupyterLab", + "JupyterLab3", + "jupyterlab-extension", + "Git" ], "scripts": { "build": "jlpm run build:lib && jlpm run build:labextension:dev", diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..7b50b3b2c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,47 @@ +[metadata] +long_description = file: README.md +long_description_content_type = text/markdown +platforms = Linux, Mac OS X, Windows +classifiers = + Intended Audience :: Developers + Intended Audience :: Science/Research + License :: OSI Approved :: BSD License + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Framework :: Jupyter + Framework :: Jupyter :: JupyterLab + Framework :: Jupyter :: JupyterLab :: 3 + Framework :: Jupyter :: JupyterLab :: Extensions + Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt + +[options] +packages = find: +install_requires = + jupyter_server + nbdime~=3.1 + nbformat + packaging + pexpect +include_package_data = True +zip_safe = False +python_requires = >=3.6,<4 + +[options.extras_require] +dev= + black + coverage + jupyter_packaging~=0.7.9 + jupyterlab~=3.0 + pre-commit + pytest + pytest-asyncio + pytest-cov + pytest-tornasync +tests= + %(dev)s + hybridcontents + diff --git a/setup.py b/setup.py index bc381a82e..16e322de4 100644 --- a/setup.py +++ b/setup.py @@ -19,9 +19,10 @@ # The name of the project name = "jupyterlab_git" -# Get our version with (HERE / "package.json").open() as f: - version = str(parse(json.load(f)["version"])) + npm_data = json.load(f) +# Get the version +version = str(parse(npm_data["version"])) lab_path = HERE / name / "labextension" @@ -64,61 +65,15 @@ else: cmdclass["jsdeps"] = skip_if_exists(jstargets, js_command) -long_description = (HERE / "README.md").read_text(errors="ignore") - setup_args = dict( name=name, version=version, - url="https://github.com/jupyterlab/jupyterlab-git.git", - author="Jupyter Development Team", - description="A JupyterLab extension for version control using git", - long_description=long_description, - long_description_content_type="text/markdown", + url=npm_data["homepage"], + author=npm_data["author"], + description=npm_data["description"], cmdclass=cmdclass, - packages=setuptools.find_packages(), - install_requires=[ - "jupyter_server", - "nbdime~=3.1", - "nbformat", - "packaging", - "pexpect", - ], - zip_safe=False, - include_package_data=True, - python_requires=">=3.6,<4", - license="BSD-3-Clause", - platforms="Linux, Mac OS X, Windows", - keywords=["Jupyter", "JupyterLab", "JupyterLab3", "Git"], - classifiers=[ - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: BSD License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Framework :: Jupyter", - "Framework :: Jupyter :: JupyterLab", - "Framework :: Jupyter :: JupyterLab :: 3", - "Framework :: Jupyter :: JupyterLab :: Extensions", - "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", - ], - extras_require={ - "dev": [ - "black", - "coverage", - "jupyter_packaging~=0.7.9", - "jupyterlab~=3.0", - "pre-commit", - "pytest", - "pytest-asyncio", - "pytest-cov", - "pytest-tornasync", - ], - "tests": ["hybridcontents"], - }, + license=npm_data["license"], + keywords=npm_data["keywords"], ) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 94fe19001..42d13bbea 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -13,7 +13,7 @@ import { FileBrowser, FileBrowserModel } from '@jupyterlab/filebrowser'; import { Contents, ContentsManager } from '@jupyterlab/services'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminal } from '@jupyterlab/terminal'; -import { TranslationBundle } from '@jupyterlab/translation'; +import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; import { closeIcon, ContextMenuSvg } from '@jupyterlab/ui-components'; import { ArrayExt, toArray } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; @@ -105,9 +105,10 @@ export function addCommands( gitModel: GitExtension, fileBrowserModel: FileBrowserModel, settings: ISettingRegistry.ISettings, - trans: TranslationBundle + translator: ITranslator ): void { const { commands, shell, serviceManager } = app; + const trans = translator.load('jupyterlab_git'); /** * Commit using a keystroke combination when in CommitBox. @@ -212,7 +213,7 @@ export function addCommands( logger.log({ message: trans.__('Failed to initialize the Git repository'), level: Level.ERROR, - error + error: error as Error }); } } @@ -329,7 +330,7 @@ export function addCommands( logger.log({ message: trans.__('Failed to clone'), level: Level.ERROR, - error + error: error as Error }); } } @@ -378,7 +379,7 @@ export function addCommands( logger.log({ message: trans.__('Failed to push'), level: Level.ERROR, - error + error: error as Error }); } } @@ -413,7 +414,7 @@ export function addCommands( logger.log({ message: trans.__('Failed to pull'), level: Level.ERROR, - error + error: error as Error }); } } @@ -436,11 +437,16 @@ export function addCommands( isText?: boolean; }; + const fullPath = PathExt.join( + model.repositoryPath ?? '/', + model.filename + ); + const buildDiffWidget = - getDiffProvider(model.filename) ?? (isText && createPlainTextDiff); + getDiffProvider(fullPath) ?? (isText && createPlainTextDiff); if (buildDiffWidget) { - const id = `diff-${model.filename}-${model.reference.label}-${model.challenger.label}`; + const id = `diff-${fullPath}-${model.reference.label}-${model.challenger.label}`; const mainAreaItems = shell.widgets('main'); let mainAreaItem = mainAreaItems.next(); while (mainAreaItem) { @@ -460,7 +466,7 @@ export function addCommands( })); diffWidget.id = id; diffWidget.title.label = PathExt.basename(model.filename); - diffWidget.title.caption = model.filename; + diffWidget.title.caption = fullPath; diffWidget.title.icon = diffIcon; diffWidget.title.closable = true; diffWidget.addClass('jp-git-diff-parent-widget'); @@ -470,7 +476,11 @@ export function addCommands( // Create the diff widget try { - const widget = await buildDiffWidget(model, diffWidget.toolbar); + const widget = await buildDiffWidget( + model, + diffWidget.toolbar, + translator + ); diffWidget.toolbar.addItem('spacer', Toolbar.createSpacerItem()); @@ -495,14 +505,14 @@ export function addCommands( try { await serviceManager.contents.save( - model.filename, + fullPath, await widget.getResolvedFile() ); await gitModel.add(model.filename); await gitModel.refresh(); } catch (reason) { logger.log({ - message: reason.message ?? reason, + message: (reason as Error).message ?? (reason as string), level: Level.ERROR }); } finally { @@ -541,7 +551,9 @@ export function addCommands( content.addWidget(widget); } catch (reason) { console.error(reason); - const msg = `Load Diff Model Error (${reason.message || reason})`; + const msg = `Load Diff Model Error (${ + (reason as Error).message || reason + })`; modelIsLoading.reject(msg); } } @@ -612,8 +624,9 @@ export function addCommands( continue; } - const repositoryPath = gitModel.getRelativeFilePath(); - const filename = PathExt.join(repositoryPath, filePath); + const repositoryPath = gitModel.pathRepository; + const filename = filePath; + const fullPath = PathExt.join(repositoryPath, filename); const specialRef = status === 'staged' ? Git.Diff.SpecialRef.INDEX @@ -622,9 +635,9 @@ export function addCommands( const diffContext: Git.Diff.IContext = status === 'unmerged' ? { - currentRef: 'HEAD', - previousRef: 'MERGE_HEAD', - baseRef: 'ORIG_HEAD' + currentRef: 'MERGE_HEAD', + previousRef: 'HEAD', + baseRef: Git.Diff.SpecialRef.BASE } : context ?? { currentRef: specialRef, @@ -643,7 +656,7 @@ export function addCommands( URLExt.join(repositoryPath, 'content'), 'POST', { - filename: filePath, + filename, reference: challengerRef } ).then(data => data.content); @@ -661,7 +674,7 @@ export function addCommands( URLExt.join(repositoryPath, 'content'), 'POST', { - filename: previousFilePath ?? filePath, + filename: previousFilePath ?? filename, reference: { git: diffContext.previousRef } } ).then(data => data.content); @@ -671,7 +684,8 @@ export function addCommands( diffContext.previousRef, source: diffContext.previousRef, updateAt: Date.now() - } + }, + repositoryPath }; // Case when file is relocated @@ -697,8 +711,10 @@ export function addCommands( URLExt.join(repositoryPath, 'content'), 'POST', { - filename: filePath, - reference: { git: diffContext.baseRef } + filename, + reference: { + special: Git.Diff.SpecialRef[diffContext.baseRef as any] + } } ).then(data => data.content); }, @@ -743,7 +759,7 @@ export function addCommands( change.newValue.last_modified ).valueOf(); if ( - change.newValue.path === filename && + change.newValue.path === fullPath && model.challenger.updateAt !== updateAt ) { model.challenger = { @@ -1314,7 +1330,7 @@ namespace Private { } catch (error) { if ( AUTH_ERROR_MESSAGES.some( - errorMessage => error.message.indexOf(errorMessage) > -1 + errorMessage => (error as Error).message.indexOf(errorMessage) > -1 ) ) { // If the error is an authentication error, ask the user credentials diff --git a/src/components/FileItem.tsx b/src/components/FileItem.tsx index cf52c58ad..4299d76b5 100644 --- a/src/components/FileItem.tsx +++ b/src/components/FileItem.tsx @@ -6,6 +6,7 @@ import { fileChangedLabelBrandStyle, fileChangedLabelInfoStyle, fileChangedLabelStyle, + fileChangedLabelWarnStyle, fileStyle, gitMarkBoxStyle, selectedFileChangedLabelStyle, @@ -149,6 +150,14 @@ export class FileItem extends React.PureComponent { selectedFileChangedLabelStyle ) : classes(fileChangedLabelStyle, fileChangedLabelBrandStyle); + } else if (change === '!') { + return this.props.selected + ? classes( + fileChangedLabelStyle, + fileChangedLabelWarnStyle, + selectedFileChangedLabelStyle + ) + : classes(fileChangedLabelStyle, fileChangedLabelWarnStyle); } else { return this.props.selected ? classes( @@ -207,7 +216,11 @@ export class FileItem extends React.PureComponent { filetype={this.props.file.type} /> {this.props.actions} - + {this.props.file.status === 'unmerged' ? '!' : this.props.file.y === '?' diff --git a/src/components/FileList.tsx b/src/components/FileList.tsx index f75bb8ce0..a05e898b1 100644 --- a/src/components/FileList.tsx +++ b/src/components/FileList.tsx @@ -9,7 +9,7 @@ import { ListChildComponentProps } from 'react-window'; import { addMenuItems, CommandArguments } from '../commandsAndMenu'; import { getDiffProvider, GitExtension } from '../model'; import { hiddenButtonStyle } from '../style/ActionButtonStyle'; -import { fileListWrapperClass, unmergedRowStyle } from '../style/FileListStyle'; +import { fileListWrapperClass } from '../style/FileListStyle'; import { addIcon, diffIcon, @@ -266,10 +266,29 @@ export class FileList extends React.Component { */ render(): JSX.Element { if (this.props.settings.composite['simpleStaging']) { + const unmergedFiles: Git.IStatusFile[] = []; + const otherFiles: Git.IStatusFile[] = []; + + this.props.files.forEach(file => { + switch (file.status) { + case 'unmerged': + unmergedFiles.push(file); + break; + default: + otherFiles.push(file); + break; + } + }); + return (
- {({ height }) => this._renderSimpleStage(this.props.files, height)} + {({ height }) => ( + <> + {this._renderUnmerged(unmergedFiles, height, false)} + {this._renderSimpleStage(otherFiles, height)} + + )}
); @@ -303,8 +322,6 @@ export class FileList extends React.Component { case 'unmerged': unmergedFiles.push(file); break; - default: - break; } }); @@ -362,7 +379,6 @@ export class FileList extends React.Component { const diffButton = this._createDiffButton(file); return ( { ); }; - private _renderUnmerged(files: Git.IStatusFile[], height: number) { + private _renderUnmerged( + files: Git.IStatusFile[], + height: number, + collapsible = true + ) { // Hide section if no merge conflicts are present return files.length > 0 ? ( => { // Create the notebook diff view - const diffWidget = new NotebookDiff(model, renderMime); + const trans = translator.load('jupyterlab_git'); + const diffWidget = new NotebookDiff(model, renderMime, trans); diffWidget.addClass('jp-git-diff-root'); await diffWidget.ready; @@ -93,13 +100,14 @@ export const createNotebookDiff = async ( checkbox.type = 'checkbox'; checkbox.checked = true; label.appendChild(checkbox); - label.appendChild(document.createElement('span')).textContent = - 'Hide unchanged cells'; + label.appendChild(document.createElement('span')).textContent = trans.__( + 'Hide unchanged cells' + ); toolbar.addItem('hideUnchanged', new Widget({ node: label })); if (model.hasConflict) { - // FIXME: Merge view breaks when moving checkboxes to the toolbar - // toolbar.addItem('clear-outputs', diffWidget.nbdimeWidget.widgets[0]) + // Move merge notebook controls in the toolbar + toolbar.addItem('clear-outputs', diffWidget.nbdimeWidget.widgets[0]); } // Connect toolbar checkbox and notebook diff widget @@ -116,12 +124,17 @@ export const createNotebookDiff = async ( * NotebookDiff widget */ export class NotebookDiff extends Panel implements Git.Diff.IDiffWidget { - constructor(model: Git.Diff.IModel, renderMime: IRenderMimeRegistry) { + constructor( + model: Git.Diff.IModel, + renderMime: IRenderMimeRegistry, + translator?: TranslationBundle + ) { super(); const getReady = new PromiseDelegate(); this._isReady = getReady.promise; this._model = model; this._renderMime = renderMime; + this._trans = translator ?? nullTranslator.load('jupyterlab_git'); this.addClass(NBDIME_CLASS); @@ -131,7 +144,7 @@ export class NotebookDiff extends Panel implements Git.Diff.IDiffWidget { }) .catch(reason => { console.error( - 'Failed to refresh Notebook diff.', + this._trans.__('Failed to refresh Notebook diff.'), reason, reason?.traceback ); @@ -221,7 +234,8 @@ export class NotebookDiff extends Panel implements Git.Diff.IDiffWidget { const header = Private.diffHeader( this._model.reference.label, this._model.challenger.label, - this._hasConflict + this._hasConflict, + this._trans.__('COMMON ANCESTOR') ); this.addWidget(header); @@ -259,11 +273,13 @@ export class NotebookDiff extends Panel implements Git.Diff.IDiffWidget { // FIXME there is a bug in nbdime and init got reject due to recursion limit hit // console.error(`Failed to init notebook diff view: ${reason}`); // getReady.reject(reason); - console.debug(`Failed to init notebook diff view: ${reason}`); + console.debug( + this._trans.__('Failed to init notebook diff view: %1', reason) + ); Private.markUnchangedRanges(this._scroller.node, this._hasConflict); } } catch (reason) { - this.showError(reason); + this.showError(reason as Error); } } @@ -317,7 +333,7 @@ export class NotebookDiff extends Panel implements Git.Diff.IDiffWidget { */ protected showError(error: Error): void { console.error( - 'Failed to load file diff.', + this._trans.__('Failed to load file diff.'), error, (error as any)?.traceback ); @@ -328,18 +344,19 @@ export class NotebookDiff extends Panel implements Git.Diff.IDiffWidget { const msg = ((error.message || error) as string).replace('\n', '
'); this.node.innerHTML = ``; } protected _areUnchangedCellsHidden = false; protected _isReady: Promise; + protected _lastSerializeModel: INotebookContent | null = null; protected _model: Git.Diff.IModel; + protected _nbdWidget: NotebookMergeWidget | NotebookDiffWidget; protected _renderMime: IRenderMimeRegistry; protected _scroller: Panel; - protected _nbdWidget: NotebookMergeWidget | NotebookDiffWidget; - protected _lastSerializeModel: INotebookContent | null = null; + protected _trans: TranslationBundle; } namespace Private { @@ -347,19 +364,23 @@ namespace Private { * Create a header widget for the diff view. */ export function diffHeader( - baseLabel: string, + localLabel: string, remoteLabel: string, - hasConflict: boolean + hasConflict: boolean, + baseLabel?: string ): Widget { + const bannerClass = hasConflict + ? 'jp-git-merge-banner' + : 'jp-git-diff-banner'; const node = document.createElement('div'); node.className = 'jp-git-diff-header'; - node.innerHTML = `