├── .all-contributorsrc ├── .copier-answers.yml ├── .editorconfig ├── .git-blame-ignore-revs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── binder-on-pr.yml │ ├── build.yml │ ├── check-release.yml │ ├── enforce-label.yml │ ├── prep-release.yml │ ├── publish-release.yml │ └── update-integration-tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .pre-commit-config.yaml ├── .prettierignore ├── .yarnrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Design.md ├── LICENSE ├── README.md ├── RELEASE.md ├── babel.config.js ├── binder ├── environment.yml └── postBuild ├── conftest.py ├── docs └── figs │ └── preview.gif ├── examples ├── demo.ipynb └── demo.txt ├── install.json ├── jest.config.js ├── jupyter-config ├── nb-config │ └── jupyterlab_git.json └── server-config │ └── jupyterlab_git.json ├── jupyterlab_git ├── __init__.py ├── git.py ├── handlers.py ├── log.py └── tests │ ├── __init__.py │ ├── conftest.py │ ├── files │ ├── inline-conflict--1.ipynb │ ├── inline-conflict--2.ipynb │ ├── inline-conflict--3.ipynb │ ├── multilevel-test-base.ipynb │ ├── multilevel-test-local.ipynb │ ├── multilevel-test-remote.ipynb │ ├── src-and-output--1.ipynb │ └── src-and-output--2.ipynb │ ├── samples │ ├── ipynb_base.json │ ├── ipynb_nbdiff.json │ └── ipynb_remote.json │ ├── test_branch.py │ ├── test_clone.py │ ├── test_config.py │ ├── test_detailed_log.py │ ├── test_diff.py │ ├── test_execute.py │ ├── test_fetch.py │ ├── test_handlers.py │ ├── test_ignore.py │ ├── test_init.py │ ├── test_integrations.py │ ├── test_jupytext.py │ ├── test_pushpull.py │ ├── test_remote.py │ ├── test_settings.py │ ├── test_single_file_log.py │ ├── test_stash.py │ ├── test_status.py │ ├── test_tag.py │ └── testutils.py ├── package.json ├── pyproject.toml ├── schema └── plugin.json ├── setup.py ├── specification └── Git_REST_API.yml ├── src ├── __tests__ │ ├── commands.spec.tsx │ ├── model.spec.tsx │ ├── plugin.spec.ts │ ├── test-components │ │ ├── BranchMenu.spec.tsx │ │ ├── CommitBox.spec.tsx │ │ ├── CommitMessage.spec.tsx │ │ ├── DiffModel.spec.tsx │ │ ├── FileItem.spec.tsx │ │ ├── GitPanel.spec.tsx │ │ ├── HistorySideBar.spec.tsx │ │ ├── ManageRemoteDialogue.spec.tsx │ │ ├── NotebookDiff.spec.tsx │ │ ├── PastCommitNode.spec.tsx │ │ ├── PlainTextDiff.spec.tsx │ │ ├── SubModuleMenu.spec.tsx │ │ ├── TagMenu.spec.tsx │ │ ├── Toolbar.spec.tsx │ │ └── data │ │ │ └── nbDiffResponse.json │ └── utils.ts ├── cancelledError.ts ├── cloneCommand.tsx ├── commandsAndMenu.tsx ├── components │ ├── ActionButton.tsx │ ├── BranchMenu.tsx │ ├── BranchPicker.tsx │ ├── CommitBox.tsx │ ├── CommitComparisonBox.tsx │ ├── CommitDiff.tsx │ ├── CommitMessage.tsx │ ├── FileItem.tsx │ ├── FileList.tsx │ ├── FilePath.tsx │ ├── GitCommitGraph.tsx │ ├── GitPanel.tsx │ ├── GitStage.tsx │ ├── GitStash.tsx │ ├── HistorySideBar.tsx │ ├── ManageRemoteDialogue.tsx │ ├── NewBranchDialog.tsx │ ├── NewTagDialog.tsx │ ├── PastCommitNode.tsx │ ├── RebaseAction.tsx │ ├── ResetRevertDialog.tsx │ ├── SelectAllButton.tsx │ ├── SinglePastCommitInfo.tsx │ ├── StatusWidget.tsx │ ├── SubmoduleMenu.tsx │ ├── TagMenu.tsx │ ├── Toolbar.tsx │ ├── WarningBox.tsx │ └── diff │ │ ├── ImageDiff.tsx │ │ ├── NotebookDiff.ts │ │ ├── PlainTextDiff.ts │ │ ├── PreviewMainAreaWidget.ts │ │ └── model.ts ├── generateGraphData.ts ├── git.ts ├── handler.ts ├── index.ts ├── model.ts ├── notifications.tsx ├── server.ts ├── style │ ├── ActionButtonStyle.ts │ ├── BranchMenu.ts │ ├── CommitBox.ts │ ├── CommitComparisonBox.ts │ ├── FileItemStyle.ts │ ├── FileListStyle.ts │ ├── FilePathStyle.ts │ ├── GitPanel.ts │ ├── GitStageStyle.ts │ ├── GitStashStyle.ts │ ├── GitWidgetStyle.ts │ ├── HistorySideBarStyle.ts │ ├── ImageDiffStyle.ts │ ├── ManageRemoteDialog.ts │ ├── NewBranchDialog.ts │ ├── NewTagDialog.ts │ ├── PastCommitNode.ts │ ├── RebaseActionStyle.ts │ ├── ResetRevertDialog.ts │ ├── SinglePastCommitInfo.ts │ ├── StatusWidget.ts │ ├── SubmoduleMenuStyle.ts │ ├── SuspendModal.ts │ ├── Toolbar.ts │ ├── common.ts │ └── icons.ts ├── svg.d.ts ├── svgPathData.ts ├── taskhandler.ts ├── tokens.ts ├── utils.ts ├── version.ts └── widgets │ ├── AdvancedPushForm.tsx │ ├── AuthorBox.ts │ ├── CredentialsBox.tsx │ ├── GitCloneForm.ts │ ├── GitResetToRemoteForm.tsx │ ├── GitWidget.tsx │ └── discardAllChanges.ts ├── style ├── advanced-push-form.css ├── base.css ├── credentials-box.css ├── diff-common.css ├── icons │ ├── add.svg │ ├── branch.svg │ ├── clock.svg │ ├── clone.svg │ ├── compare-with-selected.svg │ ├── deletions.svg │ ├── desktop.svg │ ├── diff.svg │ ├── discard.svg │ ├── git.svg │ ├── insertions.svg │ ├── merge.svg │ ├── open-file.svg │ ├── plus.svg │ ├── pull.svg │ ├── push.svg │ ├── remove.svg │ ├── rewind.svg │ ├── select-for-compare.svg │ ├── tag.svg │ ├── trash.svg │ └── vertical-more.svg ├── index.css ├── index.js ├── status-widget.css └── variables.css ├── testutils └── jest-setup-files.js ├── tsconfig.json ├── tsconfig.test.json ├── ui-tests ├── README.md ├── jupyter_server_test_config.py ├── package.json ├── playwright.config.js ├── tests │ ├── add-tag.spec.ts │ ├── commit-diff.spec.ts │ ├── commit.spec.ts │ ├── data │ │ ├── test-repository-dirty.tar.gz │ │ ├── test-repository-merge-commits.tar.gz │ │ ├── test-repository-stash.tar.gz │ │ └── test-repository.tar.gz │ ├── file-selection.spec.ts │ ├── git-stash.spec.ts │ ├── image-diff.spec.ts │ ├── image-diff.spec.ts-snapshots │ │ ├── jpeg-diff-linux.png │ │ └── png-diff-linux.png │ ├── merge-commit.spec.ts │ ├── merge-conflict.spec.ts │ ├── rebase.spec.ts │ └── utils.ts └── yarn.lock └── yarn.lock /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v4.2.1 3 | _src_path: https://github.com/jupyterlab/extension-template 4 | author_email: '' 5 | author_name: Jupyter Development Team 6 | has_binder: true 7 | has_settings: true 8 | kind: server 9 | labextension_name: '@jupyterlab/git' 10 | project_short_description: A JupyterLab extension for version control using git 11 | python_name: jupyterlab_git 12 | repository: https://github.com/jupyterlab/jupyterlab-git.git 13 | test: true 14 | 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false 13 | 14 | [*.py] 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # migrate code to black 2 | f922e2e1e60e3a964dbd07597e7c44c068b7ba1d 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 17 | 18 | ## Description 19 | 20 | 21 | 22 | ## Reproduce 23 | 24 | 25 | 26 | 1. Go to '...' 27 | 2. Click on '...' 28 | 3. Scroll down to '...' 29 | 4. See error '...' 30 | 31 | ## Expected behavior 32 | 33 | 34 | 35 | ## Context 36 | 37 | 38 | 39 | - Python package version: 40 | 41 | - Extension version: 42 | 43 | - Git version: 44 | 45 | - Operating System and its version: 46 | 47 |
Command Line Output 48 |
49 | Paste the output from your command line running `jupyter lab` here, use `--debug` if possible.
50 | 
51 |
52 | 53 |
Web Browser Output 54 |
55 | Paste the output from your browser web console here.
56 | 
57 |
58 | 59 | 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | 11 | 12 | ## Is your feature request related to a problem? Please describe. 13 | 14 | 15 | 16 | ## Describe the solution you'd like 17 | 18 | 19 | 20 | ## Describe alternatives you've considered 21 | 22 | 23 | 24 | ## Additional context 25 | 26 | 27 | 28 | - Python package version: 29 | 30 | - Extension version: 31 | 32 | - Git version: 33 | 34 | - Operating System and its version: 35 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Binder Badge 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | jobs: 7 | binder: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: jupyterlab/maintainer-tools/.github/actions/binder-link@v1 13 | with: 14 | github_token: ${{ secrets.github_token }} 15 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["*"] 7 | 8 | jobs: 9 | check_release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Base Setup 15 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 16 | - name: Check Release 17 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 18 | with: 19 | 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Upload Distributions 23 | uses: actions/upload-artifact@v4 24 | with: 25 | name: jupyterlab_git-releaser-dist-${{ github.run_number }} 26 | path: .jupyter_releaser_checkout/dist 27 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | # silent: 16 | # description: "Set a placeholder in the changelog and don't publish the release." 17 | # required: false 18 | # type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | # silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | branch: ${{ github.event.inputs.branch }} 43 | since: ${{ github.event.inputs.since }} 44 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 45 | 46 | - name: "** Next Step **" 47 | run: | 48 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 49 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 44 | with: 45 | token: ${{ steps.app-token.outputs.token }} 46 | release_url: ${{ steps.populate-release.outputs.release_url }} 47 | 48 | - name: "** Next Step **" 49 | if: ${{ success() }} 50 | run: | 51 | echo "Verify the final release" 52 | echo ${{ steps.finalize-release.outputs.release_url }} 53 | 54 | - name: "** Failure Message **" 55 | if: ${{ failure() }} 56 | run: | 57 | echo "Failed to Publish the Draft Release Url:" 58 | echo ${{ steps.populate-release.outputs.release_url }} 59 | -------------------------------------------------------------------------------- /.github/workflows/update-integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Update Playwright Snapshots 2 | 3 | on: 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | update-snapshots: 13 | if: > 14 | ( 15 | github.event.issue.author_association == 'OWNER' || 16 | github.event.issue.author_association == 'COLLABORATOR' || 17 | github.event.issue.author_association == 'MEMBER' 18 | ) && github.event.issue.pull_request && contains(github.event.comment.body, 'please update snapshots') 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: React to the triggering comment 23 | run: | 24 | gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1' 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Get PR Info 34 | id: pr 35 | env: 36 | PR_NUMBER: ${{ github.event.issue.number }} 37 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | GH_REPO: ${{ github.repository }} 39 | COMMENT_AT: ${{ github.event.comment.created_at }} 40 | run: | 41 | pr="$(gh api /repos/${GH_REPO}/pulls/${PR_NUMBER})" 42 | head_sha="$(echo "$pr" | jq -r .head.sha)" 43 | pushed_at="$(echo "$pr" | jq -r .pushed_at)" 44 | 45 | if [[ $(date -d "$pushed_at" +%s) -gt $(date -d "$COMMENT_AT" +%s) ]]; then 46 | echo "Updating is not allowed because the PR was pushed to (at $pushed_at) after the triggering comment was issued (at $COMMENT_AT)" 47 | exit 1 48 | fi 49 | 50 | echo "head_sha=$head_sha" >> $GITHUB_OUTPUT 51 | 52 | - name: Checkout the branch from the PR that triggered the job 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | run: gh pr checkout ${{ github.event.issue.number }} 56 | 57 | - name: Validate the fetched branch HEAD revision 58 | env: 59 | EXPECTED_SHA: ${{ steps.pr.outputs.head_sha }} 60 | run: | 61 | actual_sha="$(git rev-parse HEAD)" 62 | 63 | if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then 64 | echo "The HEAD of the checked out branch ($actual_sha) differs from the HEAD commit available at the time when trigger comment was submitted ($EXPECTED_SHA)" 65 | exit 1 66 | fi 67 | 68 | - name: Base Setup 69 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 70 | with: 71 | python_version: '3.10' 72 | 73 | - name: Install dependencies 74 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" jupyter-archive 75 | 76 | - name: Install extension 77 | run: | 78 | set -eux 79 | jlpm 80 | python -m pip install . 81 | 82 | - uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1 83 | with: 84 | github_token: ${{ secrets.GITHUB_TOKEN }} 85 | # Playwright knows how to start JupyterLab server 86 | start_server_script: 'null' 87 | test_folder: ui-tests 88 | npm_client: jlpm 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyterlab_git/labextension 11 | # Version file is handled by hatchling 12 | jupyterlab_git/_version.py 13 | src/version.ts 14 | 15 | # Integration tests 16 | ui-tests/test-results/ 17 | ui-tests/playwright-report/ 18 | 19 | # Created by https://www.gitignore.io/api/python 20 | # Edit at https://www.gitignore.io/?templates=python 21 | 22 | ### Python ### 23 | # Byte-compiled / optimized / DLL files 24 | __pycache__/ 25 | *.py[cod] 26 | *$py.class 27 | 28 | # C extensions 29 | *.so 30 | 31 | # Distribution / packaging 32 | .Python 33 | build/ 34 | develop-eggs/ 35 | dist/ 36 | downloads/ 37 | eggs/ 38 | .eggs/ 39 | lib/ 40 | lib64/ 41 | parts/ 42 | sdist/ 43 | var/ 44 | wheels/ 45 | pip-wheel-metadata/ 46 | share/python-wheels/ 47 | .installed.cfg 48 | *.egg 49 | MANIFEST 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .nox/ 65 | .coverage 66 | .coverage.* 67 | .cache 68 | nosetests.xml 69 | coverage/ 70 | coverage.xml 71 | *.cover 72 | .hypothesis/ 73 | .pytest_cache/ 74 | 75 | # Translations 76 | *.mo 77 | *.pot 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # celery beat schedule file 92 | celerybeat-schedule 93 | 94 | # SageMath parsed files 95 | *.sage.py 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # Mr Developer 105 | .mr.developer.cfg 106 | .project 107 | .pydevproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | .dmypy.json 115 | dmypy.json 116 | 117 | # Pyre type checker 118 | .pyre/ 119 | 120 | # End of https://www.gitignore.io/api/python 121 | 122 | # OSX files 123 | .DS_Store 124 | 125 | # Yarn cache 126 | .yarn/ 127 | 128 | # JetBrains IDE stuff 129 | *.iml 130 | .idea/ 131 | 132 | # vscode ide stuff 133 | *.code-workspace 134 | .history 135 | .vscode 136 | 137 | # vim stuff 138 | *.swp 139 | 140 | # virtual env 141 | venv/ 142 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | yarn run lint-staged 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 24.4.2 # Replace by any tag/version: https://github.com/psf/black/tags 4 | hooks: 5 | - id: black 6 | language_version: python3 # Should be a command that runs python3.6+ 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyterlab_git 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | You can contribute in many ways to move this project forward. 4 | 5 | While anyone can contribute, only [Team Members](https://github.com/jupyterlab/jupyterlab-git#team) can merge in pull requests 6 | or add labels to issues. 7 | 8 | Here we outline how the different contribution processes play out in practice for this project. 9 | The goal is to be transparent about these, so that anyone can see how to participate. 10 | 11 | If you have suggestions on how these processes can be improved, please suggest that (see "Enhancement Request" below)! 12 | 13 | ## Bug Report 14 | 15 | If you are using this software and encounter some behavior that is unexpected, then you may have come across a bug! 16 | To get this fixed, first creation an issue that should have, ideally: 17 | 18 | - The behavior you expected 19 | - The actual behavior (screenshots can be helpful here) 20 | - How someone else could reproduce it (version of the software, as well as your browser and OS can help) 21 | 22 | Once you create this issue, someone with commit rights should come by and try to reproduce the issue locally and comment if they are able to. If they are able to, then they will add the `type:Bug` label. If they are not able to, then they will add the `status: Needs info` label and wait for information from you. 23 | 24 | Hopefully, then some nice person will come by to fix your bug! This will likely be someone who already works on the project, 25 | but it could be anyone. 26 | 27 | They will fix the bug locally, then push those changes to their fork. Then they will make a pull request, and in the description 28 | say "This fixes bug #xxx". 29 | 30 | Someone who maintains the repo will review this change, and this can lead to some more back and forth about the implementation. 31 | 32 | Finally, once at least one person with commit rights is happy with the change, and there aren't any objections, they will merge 33 | it in. 34 | 35 | ## Enhancement Request 36 | 37 | Maybe the current behavior isn't wrong, but you still have an idea on how it could be improved. 38 | 39 | The flow will be similar to opening a bug, but the process could be longer, as we all work together to agree on what 40 | behavior should be added. So when you open an issue, it's helpful to give some context around what you are trying to achieve, 41 | why that is important, where the current functionality falls short, and any ideas you have on how it could be improved. 42 | 43 | These issues should get a `type:Enhancement` label. If the solution seems obvious enough and you think others will agree, 44 | then anyone is welcome to implement the solution and propose it in a pull request. 45 | 46 | However, if the issue is multifaceted or has many different good options, then there will likely need to be some discussion 47 | first. In this case, a maintainer should add a `status:Needs Discussion` label. Then there will be some period of time where 48 | anyone who has a stake in this issue or ideas on how to solve it should work together to come up with a coherent solution. 49 | 50 | Once there seem to be some consensus around how to move forward, then someone can proceed to implementing the changes. 51 | -------------------------------------------------------------------------------- /Design.md: -------------------------------------------------------------------------------- 1 | ## Product Goals: 2 | 3 | - Give users a good set of handrails so in their use of the UI, they don’t get themselves stuck. 4 | - Provide a ‘happy path’ that the extension doesn’t deviate from. Make it easy for users to follow this happy path and difficult for them to deviate from it. 5 | - Help establish a good working rhythm with Git that will help them develop a useful mental model . 6 | - Expose the most frequently used commands that cover the majority of daily git use. 7 | - Increase Usability of Git 8 | - Shield user from repetitive actions. 9 | - Increase visibility of the state of Git Repos; surface state-related information. 10 | - Unblock advanced commands by easing transition to terminal when necessary. 11 | 12 | ## Research Requirements: 13 | 14 | - Surface the most used commands, and in what context they are used. 15 | - Discover Git functionality that is frequently helpful, but difficult to execute. 16 | 17 | ## Design Requirements: 18 | 19 | ### First Stage 20 | 21 | - Add remote functionality (Push to Origin [default], Pull from Origin [default], Expose Remotes?) 22 | - Merge/diff conflicts handled in the terminal 23 | - Branches 24 | - Add new Branch 25 | - Switch Branches 26 | - Surface reasons for disabled actions (create/switch) 27 | - Repos (Through File Browser) 28 | - Clone Repo (Button in file browser) 29 | - Init Repo (In file menu) ← Expose this elsewhere? 30 | - Select Repo (Through File Browser) ← Give feedback in filebrowser as to existence of repo? 31 | - Commits 32 | - Commit 33 | - Stage 34 | - Remove 35 | 36 | ### First Stage Stretch goals. 37 | 38 | - Give Git feedback in the file browsers 39 | - Allow users to link to the git extension directly from the file browser. 40 | - Add Stash Functionality. 41 | - Migrate changes to new branch when created. 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Jupyter Development Team 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupyterlab_git 2 | 3 | The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser). 4 | 5 | ## Manual release 6 | 7 | ### Python package 8 | 9 | This extension can be distributed as Python packages. All of the Python 10 | packaging instructions are in the `pyproject.toml` file to wrap your extension in a 11 | Python package. Before generating a package, you first need to install some tools: 12 | 13 | ```bash 14 | pip install build twine hatch 15 | ``` 16 | 17 | Bump the version using `hatch`. By default this will create a tag. 18 | See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 19 | 20 | ```bash 21 | hatch version 22 | ``` 23 | 24 | Make sure to clean up all the development files before building the package: 25 | 26 | ```bash 27 | jlpm clean:all 28 | ``` 29 | 30 | You could also clean up the local git repository: 31 | 32 | ```bash 33 | git clean -dfX 34 | ``` 35 | 36 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 37 | 38 | ```bash 39 | python -m build 40 | ``` 41 | 42 | > `python setup.py sdist bdist_wheel` is deprecated and will not work for this package. 43 | 44 | Then to upload the package to PyPI, do: 45 | 46 | ```bash 47 | twine upload dist/* 48 | ``` 49 | 50 | ### NPM package 51 | 52 | To publish the frontend part of the extension as a NPM package, do: 53 | 54 | ```bash 55 | npm login 56 | npm publish --access public 57 | ``` 58 | 59 | ## Automated releases with the Jupyter Releaser 60 | 61 | The extension repository should already be compatible with the Jupyter Releaser. 62 | 63 | Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) for more information. 64 | 65 | Here is a summary of the steps to cut a new release: 66 | 67 | - Add tokens to the [Github Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in the repository: 68 | - `ADMIN_GITHUB_TOKEN` (with "public_repo" and "repo:status" permissions); see the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) 69 | - `NPM_TOKEN` (with "automation" permission); see the [documentation](https://docs.npmjs.com/creating-and-viewing-access-tokens) 70 | - Set up PyPI 71 | 72 |
Using PyPI trusted publisher (modern way) 73 | 74 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 75 | - The _workflow name_ is `publish-release.yml` and the _environment_ should be left blank. 76 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 77 | 78 |
79 | 80 | - Go to the Actions panel 81 | - Run the "Step 1: Prep Release" workflow 82 | - Check the draft changelog 83 | - Run the "Step 2: Publish Release" workflow 84 | 85 | ## Publishing to `conda-forge` 86 | 87 | If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html 88 | 89 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 90 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@jupyterlab/testutils/lib/babel.config'); 2 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | # a mybinder.org-ready environment for demoing jupyterlab_git 2 | # this environment may also be used locally on Linux/MacOS/Windows, e.g. 3 | # 4 | # conda env update --file binder/environment.yml 5 | # conda activate jupyterlab-git-demo 6 | # 7 | name: jupyterlab-git-demo 8 | 9 | channels: 10 | - conda-forge 11 | 12 | dependencies: 13 | # runtime dependencies 14 | - python >=3.10,<3.11.0a0 15 | - jupyterlab >=4.0.0,<5 16 | # labextension build dependencies 17 | - nodejs >=18,<19 18 | - pip 19 | - wheel 20 | # additional packages for demos 21 | - nbgitpuller 22 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of jupyterlab_git 3 | 4 | On Binder, this will run _after_ the environment has been fully created from 5 | the environment.yml in this directory. 6 | 7 | This script should also run locally on Linux/MacOS/Windows: 8 | 9 | python3 binder/postBuild 10 | """ 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | 15 | 16 | ROOT = Path.cwd() 17 | 18 | def _(*args, **kwargs): 19 | """ Run a command, echoing the args 20 | 21 | fails hard if something goes wrong 22 | """ 23 | print("\n\t", " ".join(args), "\n") 24 | return_code = subprocess.call(args, **kwargs) 25 | if return_code != 0: 26 | print("\nERROR", return_code, " ".join(args)) 27 | sys.exit(return_code) 28 | 29 | # verify the environment is self-consistent before even starting 30 | _(sys.executable, "-m", "pip", "check") 31 | 32 | # install the labextension 33 | _(sys.executable, "-m", "pip", "install", "-e", ".") 34 | _(sys.executable, "-m", "jupyter", "labextension", "develop", "--overwrite", ".") 35 | _( 36 | sys.executable, 37 | "-m", 38 | "jupyter", 39 | "server", 40 | "extension", 41 | "enable", 42 | "jupyterlab_git", 43 | ) 44 | 45 | # verify the environment the extension didn't break anything 46 | _(sys.executable, "-m", "pip", "check") 47 | 48 | # list the extensions 49 | _("jupyter", "server", "extension", "list") 50 | 51 | # initially list installed extensions to determine if there are any surprises 52 | _("jupyter", "labextension", "list") 53 | 54 | 55 | print("JupyterLab with jupyterlab_git is ready to run with:\n") 56 | print("\tjupyter lab\n") 57 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest_plugins = ("pytest_jupyter.jupyter_server",) 4 | 5 | 6 | @pytest.fixture 7 | def jp_server_config(jp_server_config): 8 | return { 9 | "ServerApp": {"jpserver_extensions": {"jupyterlab_git": True}}, 10 | "JupyterLabGit": {"excluded_paths": ["/ignored-path/*"]}, 11 | } 12 | -------------------------------------------------------------------------------- /docs/figs/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/docs/figs/preview.gif -------------------------------------------------------------------------------- /examples/demo.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin luctus nec arcu quis accumsan. Vivamus facilisis egestas commodo. 2 | Aenean mollis sodales auctor. Vestibulum fermentum feugiat dui efficitur porttitor. Maecenas id lectus velit. Phasellus vel 3 | quam faucibus, tristique velit vitae, laoreet dui. Vivamus id eros finibus, dictum risus eu, placerat lectus. Suspendisse 4 | potenti. Proin fermentum, magna sed finibus rutrum, felis enim sollicitudin felis, at condimentum urna elit vel velit. Class 5 | aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec et massa sed dolor ornare suscipit. 6 | Vivamus hendrerit turpis ligula, blandit tincidunt metus volutpat eu. Nulla tellus ligula, tempus et purus dapibus, molestie 7 | blandit ex. Donec convallis magna magna, eu iaculis ex venenatis in. Nam at convallis dolor. Aliquam erat volutpat. 8 | 9 | Fusce varius lectus vitae tellus mollis ultrices. Ut id gravida ipsum. Vivamus eleifend felis in aliquet pellentesque. Nullam 10 | vitae placerat lacus. Proin accumsan, massa eget mollis convallis, justo augue dapibus leo, at luctus velit elit nec ipsum. Ut 11 | eros nisi, iaculis in lorem vel, fermentum hendrerit magna. Orci varius natoque penatibus et magnis dis parturient montes, 12 | nascetur ridiculus mus. Etiam non faucibus eros. Aenean nisl massa, facilisis ac quam in, elementum consectetur risus. Maecenas 13 | sagittis rhoncus orci at egestas. 14 | 15 | Vivamus nec odio ac libero porttitor mattis. Morbi ac tincidunt velit, a aliquet ipsum. Etiam a aliquet massa. In dapibus, 16 | ex malesuada aliquam dictum, enim ante suscipit est, in tempus tortor felis sed nunc. Vestibulum ante ipsum primis in faucibus 17 | orci luctus et ultrices posuere cubilia curae; Phasellus sodales sit amet justo gravida sagittis. Pellentesque habitant morbi 18 | tristique senectus et netus et malesuada fames ac turpis egestas. Ut vulputate facilisis felis, ac scelerisque tortor 19 | condimentum sed. Nulla ut consequat risus. Aenean volutpat facilisis luctus. Phasellus at egestas sapien, in blandit dolor. 20 | Vestibulum commodo ligula ut orci rhoncus, eu cursus diam luctus. Pellentesque at accumsan tortor, non tempor nunc. Phasellus 21 | ultricies consequat libero, quis tempus mauris auctor quis. Fusce bibendum augue sed augue sollicitudin, eu volutpat turpis 22 | vestibulum. Proin auctor aliquam nisi a dapibus. 23 | 24 | Nam eget finibus elit. Cras in sapien ante. Curabitur facilisis interdum ligula, ut molestie orci molestie sit amet. Etiam 25 | euismod rhoncus velit, sit amet tempor magna egestas quis. In sed nunc porta, tincidunt risus ornare, elementum lorem. Class 26 | aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed quis velit ac leo eleifend efficitur 27 | at id velit. In et ante tempus, sollicitudin dui at, euismod est. Aenean eleifend scelerisque turpis, id egestas turpis dictum 28 | nec. 29 | 30 | Praesent luctus, neque et egestas hendrerit, lorem sapien varius lacus, sit amet tincidunt nisi orci quis lacus. Pellentesque 31 | suscipit accumsan mi vel convallis. Fusce ullamcorper scelerisque augue id sollicitudin. Curabitur tempus nec diam in 32 | pellentesque. Maecenas suscipit ex id facilisis posuere. Proin rutrum blandit leo. Donec bibendum velit vel ipsum mattis rutrum. 33 | Nulla eu enim vel neque ultricies hendrerit eget sed turpis. Mauris efficitur lectus id mi sollicitudin, ultricies tempor 34 | tellus molestie. Phasellus mollis odio risus, ut fringilla tellus eleifend ac. Interdum et malesuada fames ac ante ipsum 35 | primis in faucibus. Nulla eu neque consequat sem aliquam semper quis eget arcu. 36 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyterlab-git", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab-git" 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config'); 2 | 3 | const esModules = [ 4 | '@codemirror', 5 | '@jupyter/ydoc', 6 | '@jupyterlab/', 7 | 'lib0', 8 | 'nanoid', 9 | 'nbdime', 10 | 'vscode-ws-jsonrpc', 11 | 'y-protocols', 12 | 'y-websocket', 13 | 'yjs' 14 | ].join('|'); 15 | 16 | const baseConfig = jestJupyterLab(__dirname); 17 | 18 | module.exports = { 19 | ...baseConfig, 20 | automock: false, 21 | collectCoverageFrom: [ 22 | 'src/**/*.{ts,tsx}', 23 | '!src/**/*.d.ts', 24 | '!src/**/.ipynb_checkpoints/*' 25 | ], 26 | coverageDirectory: 'coverage', 27 | coverageReporters: ['lcov', 'text'], 28 | modulePathIgnorePatterns: [ 29 | '/build', 30 | '/jupyterlab_git', 31 | '/jupyter-config', 32 | '/ui-tests' 33 | ], 34 | reporters: ['default', 'github-actions'], 35 | setupFiles: ['/testutils/jest-setup-files.js'], 36 | testRegex: 'src/.*/.*.spec.ts[x]?$', 37 | transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`] 38 | }; 39 | -------------------------------------------------------------------------------- /jupyter-config/nb-config/jupyterlab_git.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyterlab_git": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/server-config/jupyterlab_git.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyterlab_git": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyterlab_git/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize the backend server extension""" 2 | 3 | from traitlets import CFloat, List, Dict, Unicode, default 4 | from traitlets.config import Configurable 5 | 6 | try: 7 | from ._version import __version__ 8 | except: 9 | import warnings 10 | 11 | warnings.warn( 12 | "Did you forget to install the extension in editable mode `pip install -e .`?" 13 | ) 14 | __version__ = "dev" 15 | from .handlers import setup_handlers 16 | from .git import Git 17 | 18 | 19 | def _jupyter_labextension_paths(): 20 | return [{"src": "labextension", "dest": "@jupyterlab/git"}] 21 | 22 | 23 | class JupyterLabGit(Configurable): 24 | """ 25 | Config options for jupyterlab_git 26 | 27 | Modeled after: https://github.com/jupyter/jupyter_server/blob/9dd2a9a114c045cfd8fd8748400c6a697041f7fa/jupyter_server/serverapp.py#L1040 28 | """ 29 | 30 | actions = Dict( 31 | help="Actions to be taken after a git command. Each action takes a list of commands to execute (strings). Supported actions: post_init", 32 | config=True, 33 | value_trait=List( 34 | trait=Unicode(), help='List of commands to run. E.g. ["touch baz.py"]' 35 | ), 36 | # TODO Validate 37 | ) 38 | 39 | excluded_paths = List(help="Paths to be excluded", config=True, trait=Unicode()) 40 | 41 | credential_helper = Unicode( 42 | help=""" 43 | The value of Git credential helper will be set to this value when the Git credential caching mechanism is activated by this extension. 44 | By default it is an in-memory cache of 3600 seconds (1 hour); `cache --timeout=3600`. 45 | """, 46 | config=True, 47 | ) 48 | 49 | git_command_timeout = CFloat( 50 | help="The timeout for executing git operations. By default it is set to 20 seconds.", 51 | config=True, 52 | ) 53 | 54 | @default("credential_helper") 55 | def _credential_helper_default(self): 56 | return "cache --timeout=3600" 57 | 58 | @default("git_command_timeout") 59 | def _git_command_timeout_default(self): 60 | return 20.0 61 | 62 | 63 | def _jupyter_server_extension_points(): 64 | return [{"module": "jupyterlab_git"}] 65 | 66 | 67 | def _load_jupyter_server_extension(server_app): 68 | """Registers the API handler to receive HTTP requests from the frontend extension. 69 | 70 | Parameters 71 | ---------- 72 | server_app: jupyterlab.labapp.LabApp 73 | JupyterLab application instance 74 | """ 75 | config = JupyterLabGit(config=server_app.config) 76 | server_app.web_app.settings["git"] = Git(config) 77 | setup_handlers(server_app.web_app) 78 | 79 | 80 | # For backward compatibility 81 | load_jupyter_server_extension = _load_jupyter_server_extension 82 | -------------------------------------------------------------------------------- /jupyterlab_git/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from traitlets.config import Application 4 | 5 | 6 | class _ExtensionLogger: 7 | _LOGGER = None # type: Optional[logging.Logger] 8 | 9 | @classmethod 10 | def get_logger(cls) -> logging.Logger: 11 | if cls._LOGGER is None: 12 | app = Application.instance() 13 | cls._LOGGER = logging.getLogger("{!s}.jupyterlab_git".format(app.log.name)) 14 | 15 | return cls._LOGGER 16 | 17 | 18 | get_logger = _ExtensionLogger.get_logger 19 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/jupyterlab_git/tests/__init__.py -------------------------------------------------------------------------------- /jupyterlab_git/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | # 4 | # Inspired by nbdime conftest 5 | 6 | import os 7 | import shlex 8 | import shutil 9 | import sys 10 | from pathlib import Path 11 | from subprocess import check_call 12 | from typing import Callable, Dict, List, Union 13 | 14 | from pytest import fixture, skip 15 | 16 | FILES_PATH = Path(__file__).parent / "files" 17 | 18 | 19 | def call(cmd: Union[str, List[str]], cwd: Union[str, Path, None] = None) -> int: 20 | """Call a command 21 | if str, split into command list 22 | """ 23 | if isinstance(cmd, str): 24 | cmd = shlex.split(cmd) 25 | return check_call(cmd, stdout=sys.stdout, stderr=sys.stderr, cwd=cwd) 26 | 27 | 28 | @fixture(scope="session") 29 | def needs_symlink(tmp_path_factory): 30 | if not hasattr(os, "symlink"): 31 | skip("requires symlink creation") 32 | tdir = tmp_path_factory.mktemp("check-symlinks") 33 | source = tdir / "source" 34 | source.mkdir() 35 | try: 36 | os.symlink(source, tdir / "link") 37 | except OSError: 38 | skip("requires symlink creation") 39 | 40 | 41 | @fixture 42 | def git_repo_factory() -> Callable[[Path], Path]: 43 | def factory(root_path: Path) -> Path: 44 | repo = root_path / "repo" 45 | repo.mkdir() 46 | 47 | call("git init", cwd=repo) 48 | 49 | # setup base branch 50 | src = FILES_PATH 51 | 52 | def copy(files): 53 | for s, d in files: 54 | shutil.copy(src / s, repo / d) 55 | 56 | copy( 57 | [ 58 | ["multilevel-test-base.ipynb", "merge-no-conflict.ipynb"], 59 | ["inline-conflict--1.ipynb", "merge-conflict.ipynb"], 60 | ["src-and-output--1.ipynb", "diff.ipynb"], 61 | ] 62 | ) 63 | 64 | call("git add *.ipynb", cwd=repo) 65 | call("git config user.name 'JupyterLab Git'", cwd=repo) 66 | call("git config user.email 'jlab.git@py.test'", cwd=repo) 67 | call('git commit -m "init base branch"', cwd=repo) 68 | # create base alias for master 69 | call("git checkout -b base master", cwd=repo) 70 | 71 | # setup local branch 72 | call("git checkout -b local master", cwd=repo) 73 | copy( 74 | [ 75 | ["multilevel-test-local.ipynb", "merge-no-conflict.ipynb"], 76 | ["inline-conflict--2.ipynb", "merge-conflict.ipynb"], 77 | ["src-and-output--2.ipynb", "diff.ipynb"], 78 | ] 79 | ) 80 | call('git commit -am "create local branch"', cwd=repo) 81 | 82 | # setup remote branch with conflict 83 | call("git checkout -b remote-conflict master", cwd=repo) 84 | copy([["inline-conflict--3.ipynb", "merge-conflict.ipynb"]]) 85 | call('git commit -am "create remote with conflict"', cwd=repo) 86 | 87 | # setup remote branch with no conflict 88 | call("git checkout -b remote-no-conflict master", cwd=repo) 89 | copy([["multilevel-test-remote.ipynb", "merge-no-conflict.ipynb"]]) 90 | call('git commit -am "create remote with no conflict"', cwd=repo) 91 | 92 | # start on local 93 | call("git checkout local", cwd=repo) 94 | assert not Path(repo / ".gitattributes").exists() 95 | return repo 96 | 97 | return factory 98 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/inline-conflict--1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "collapsed": false 8 | }, 9 | "outputs": [ 10 | { 11 | "name": "stdout", 12 | "output_type": "stream", 13 | "text": [ 14 | "3\n" 15 | ] 16 | } 17 | ], 18 | "source": [ 19 | "x = 1\n", 20 | "y = 3\n", 21 | "print(x * y)" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": { 28 | "collapsed": true 29 | }, 30 | "outputs": [], 31 | "source": [] 32 | } 33 | ], 34 | "metadata": { 35 | "kernelspec": { 36 | "display_name": "Python 2", 37 | "language": "python", 38 | "name": "python2" 39 | }, 40 | "language_info": { 41 | "codemirror_mode": { 42 | "name": "ipython", 43 | "version": 2 44 | }, 45 | "file_extension": ".py", 46 | "mimetype": "text/x-python", 47 | "name": "python", 48 | "nbconvert_exporter": "python", 49 | "pygments_lexer": "ipython2", 50 | "version": "2.7.12" 51 | } 52 | }, 53 | "nbformat": 4, 54 | "nbformat_minor": 1 55 | } 56 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/inline-conflict--2.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "collapsed": false 8 | }, 9 | "outputs": [ 10 | { 11 | "name": "stdout", 12 | "output_type": "stream", 13 | "text": [ 14 | "0\n" 15 | ] 16 | } 17 | ], 18 | "source": [ 19 | "x = 1\n", 20 | "y = 3\n", 21 | "z = 4\n", 22 | "print(x * y / z)" 23 | ] 24 | }, 25 | { 26 | "cell_type": "code", 27 | "execution_count": null, 28 | "metadata": { 29 | "collapsed": true 30 | }, 31 | "outputs": [], 32 | "source": [] 33 | } 34 | ], 35 | "metadata": { 36 | "kernelspec": { 37 | "display_name": "Python 2", 38 | "language": "python", 39 | "name": "python2" 40 | }, 41 | "language_info": { 42 | "codemirror_mode": { 43 | "name": "ipython", 44 | "version": 2 45 | }, 46 | "file_extension": ".py", 47 | "mimetype": "text/x-python", 48 | "name": "python", 49 | "nbconvert_exporter": "python", 50 | "pygments_lexer": "ipython2", 51 | "version": "2.7.12" 52 | } 53 | }, 54 | "nbformat": 4, 55 | "nbformat_minor": 1 56 | } 57 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/inline-conflict--3.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "collapsed": false 8 | }, 9 | "outputs": [ 10 | { 11 | "name": "stdout", 12 | "output_type": "stream", 13 | "text": [ 14 | "3\n" 15 | ] 16 | } 17 | ], 18 | "source": [ 19 | "x = 1\n", 20 | "q = 3.1\n", 21 | "print(x + q)" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": { 28 | "collapsed": true 29 | }, 30 | "outputs": [], 31 | "source": [] 32 | } 33 | ], 34 | "metadata": { 35 | "kernelspec": { 36 | "display_name": "Python 2", 37 | "language": "python", 38 | "name": "python2" 39 | }, 40 | "language_info": { 41 | "codemirror_mode": { 42 | "name": "ipython", 43 | "version": 2 44 | }, 45 | "file_extension": ".py", 46 | "mimetype": "text/x-python", 47 | "name": "python", 48 | "nbconvert_exporter": "python", 49 | "pygments_lexer": "ipython2", 50 | "version": "2.7.12" 51 | } 52 | }, 53 | "nbformat": 4, 54 | "nbformat_minor": 1 55 | } 56 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/multilevel-test-base.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "collapsed": true 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "def f(x):\n", 12 | " return x**2" 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": 2, 18 | "metadata": { 19 | "collapsed": true 20 | }, 21 | "outputs": [], 22 | "source": [ 23 | "x = 3" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 3, 29 | "metadata": { 30 | "collapsed": false 31 | }, 32 | "outputs": [ 33 | { 34 | "data": { 35 | "text/plain": [ 36 | "9" 37 | ] 38 | }, 39 | "execution_count": 3, 40 | "metadata": {}, 41 | "output_type": "execute_result" 42 | } 43 | ], 44 | "source": [ 45 | "f(x)" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": 4, 51 | "metadata": { 52 | "collapsed": true 53 | }, 54 | "outputs": [], 55 | "source": [ 56 | "x = 3" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": 5, 62 | "metadata": { 63 | "collapsed": false 64 | }, 65 | "outputs": [ 66 | { 67 | "data": { 68 | "text/plain": [ 69 | "9" 70 | ] 71 | }, 72 | "execution_count": 5, 73 | "metadata": {}, 74 | "output_type": "execute_result" 75 | } 76 | ], 77 | "source": [ 78 | "f(x)" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "metadata": { 85 | "collapsed": true 86 | }, 87 | "outputs": [], 88 | "source": [] 89 | } 90 | ], 91 | "metadata": { 92 | "kernelspec": { 93 | "display_name": "Python 2", 94 | "language": "python", 95 | "name": "python2" 96 | }, 97 | "language_info": { 98 | "codemirror_mode": { 99 | "name": "ipython", 100 | "version": 2 101 | }, 102 | "file_extension": ".py", 103 | "mimetype": "text/x-python", 104 | "name": "python", 105 | "nbconvert_exporter": "python", 106 | "pygments_lexer": "ipython2", 107 | "version": "2.7.11" 108 | } 109 | }, 110 | "nbformat": 4, 111 | "nbformat_minor": 0 112 | } 113 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/multilevel-test-local.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "collapsed": true 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "def f(x):\n", 12 | " return x**2" 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": 2, 18 | "metadata": { 19 | "collapsed": true 20 | }, 21 | "outputs": [], 22 | "source": [ 23 | "x = 5" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 3, 29 | "metadata": { 30 | "collapsed": false 31 | }, 32 | "outputs": [ 33 | { 34 | "data": { 35 | "text/plain": [ 36 | "25" 37 | ] 38 | }, 39 | "execution_count": 3, 40 | "metadata": {}, 41 | "output_type": "execute_result" 42 | } 43 | ], 44 | "source": [ 45 | "f(x)" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": 4, 51 | "metadata": { 52 | "collapsed": true 53 | }, 54 | "outputs": [], 55 | "source": [ 56 | "x = 3" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": 5, 62 | "metadata": { 63 | "collapsed": false 64 | }, 65 | "outputs": [ 66 | { 67 | "data": { 68 | "text/plain": [ 69 | "9" 70 | ] 71 | }, 72 | "execution_count": 5, 73 | "metadata": {}, 74 | "output_type": "execute_result" 75 | } 76 | ], 77 | "source": [ 78 | "f(x)" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "metadata": { 85 | "collapsed": true 86 | }, 87 | "outputs": [], 88 | "source": [] 89 | } 90 | ], 91 | "metadata": { 92 | "kernelspec": { 93 | "display_name": "Python 2", 94 | "language": "python", 95 | "name": "python2" 96 | }, 97 | "language_info": { 98 | "codemirror_mode": { 99 | "name": "ipython", 100 | "version": 2 101 | }, 102 | "file_extension": ".py", 103 | "mimetype": "text/x-python", 104 | "name": "python", 105 | "nbconvert_exporter": "python", 106 | "pygments_lexer": "ipython2", 107 | "version": "2.7.11" 108 | } 109 | }, 110 | "nbformat": 4, 111 | "nbformat_minor": 0 112 | } 113 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/multilevel-test-remote.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "collapsed": true 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "def f(x):\n", 12 | " return x**2" 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": 2, 18 | "metadata": { 19 | "collapsed": true 20 | }, 21 | "outputs": [], 22 | "source": [ 23 | "x = 3" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 3, 29 | "metadata": { 30 | "collapsed": false 31 | }, 32 | "outputs": [ 33 | { 34 | "data": { 35 | "text/plain": [ 36 | "9" 37 | ] 38 | }, 39 | "execution_count": 3, 40 | "metadata": {}, 41 | "output_type": "execute_result" 42 | } 43 | ], 44 | "source": [ 45 | "f(x)" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": 4, 51 | "metadata": { 52 | "collapsed": true 53 | }, 54 | "outputs": [], 55 | "source": [ 56 | "x = 7" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": 5, 62 | "metadata": { 63 | "collapsed": false 64 | }, 65 | "outputs": [ 66 | { 67 | "data": { 68 | "text/plain": [ 69 | "49" 70 | ] 71 | }, 72 | "execution_count": 5, 73 | "metadata": {}, 74 | "output_type": "execute_result" 75 | } 76 | ], 77 | "source": [ 78 | "f(x)" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "metadata": { 85 | "collapsed": true 86 | }, 87 | "outputs": [], 88 | "source": [] 89 | } 90 | ], 91 | "metadata": { 92 | "kernelspec": { 93 | "display_name": "Python 2", 94 | "language": "python", 95 | "name": "python2" 96 | }, 97 | "language_info": { 98 | "codemirror_mode": { 99 | "name": "ipython", 100 | "version": 2 101 | }, 102 | "file_extension": ".py", 103 | "mimetype": "text/x-python", 104 | "name": "python", 105 | "nbconvert_exporter": "python", 106 | "pygments_lexer": "ipython2", 107 | "version": "2.7.11" 108 | } 109 | }, 110 | "nbformat": 4, 111 | "nbformat_minor": 0 112 | } 113 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/src-and-output--1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "### This notebook contains cells that have identical source or output, a notebook-specific challenge for the diff algorithm" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 1, 13 | "metadata": { 14 | "collapsed": true 15 | }, 16 | "outputs": [], 17 | "source": [ 18 | "x = 3" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": 2, 24 | "metadata": { 25 | "collapsed": false 26 | }, 27 | "outputs": [ 28 | { 29 | "data": { 30 | "text/plain": [ 31 | "3" 32 | ] 33 | }, 34 | "execution_count": 2, 35 | "metadata": {}, 36 | "output_type": "execute_result" 37 | } 38 | ], 39 | "source": [ 40 | "x" 41 | ] 42 | }, 43 | { 44 | "cell_type": "code", 45 | "execution_count": null, 46 | "metadata": { 47 | "collapsed": false 48 | }, 49 | "outputs": [], 50 | "source": [ 51 | "x" 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": 3, 57 | "metadata": { 58 | "collapsed": true 59 | }, 60 | "outputs": [], 61 | "source": [ 62 | "x = 5" 63 | ] 64 | }, 65 | { 66 | "cell_type": "code", 67 | "execution_count": null, 68 | "metadata": { 69 | "collapsed": false 70 | }, 71 | "outputs": [], 72 | "source": [ 73 | "x" 74 | ] 75 | }, 76 | { 77 | "cell_type": "code", 78 | "execution_count": 4, 79 | "metadata": { 80 | "collapsed": false 81 | }, 82 | "outputs": [ 83 | { 84 | "data": { 85 | "text/plain": [ 86 | "5" 87 | ] 88 | }, 89 | "execution_count": 4, 90 | "metadata": {}, 91 | "output_type": "execute_result" 92 | } 93 | ], 94 | "source": [ 95 | "x" 96 | ] 97 | } 98 | ], 99 | "metadata": { 100 | "kernelspec": { 101 | "display_name": "Python 2", 102 | "language": "python", 103 | "name": "python2" 104 | }, 105 | "language_info": { 106 | "codemirror_mode": { 107 | "name": "ipython", 108 | "version": 2 109 | }, 110 | "file_extension": ".py", 111 | "mimetype": "text/x-python", 112 | "name": "python", 113 | "nbconvert_exporter": "python", 114 | "pygments_lexer": "ipython2", 115 | "version": "2.7.11" 116 | } 117 | }, 118 | "nbformat": 4, 119 | "nbformat_minor": 0 120 | } 121 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/files/src-and-output--2.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "### This notebook contains cells that have identical source or output, a notebook-specific challenge for the diff algorithm" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 1, 13 | "metadata": { 14 | "collapsed": true 15 | }, 16 | "outputs": [], 17 | "source": [ 18 | "x = 3" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": { 25 | "collapsed": false 26 | }, 27 | "outputs": [], 28 | "source": [ 29 | "x" 30 | ] 31 | }, 32 | { 33 | "cell_type": "code", 34 | "execution_count": 2, 35 | "metadata": { 36 | "collapsed": false 37 | }, 38 | "outputs": [ 39 | { 40 | "data": { 41 | "text/plain": [ 42 | "3" 43 | ] 44 | }, 45 | "execution_count": 2, 46 | "metadata": {}, 47 | "output_type": "execute_result" 48 | } 49 | ], 50 | "source": [ 51 | "x" 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": 3, 57 | "metadata": { 58 | "collapsed": true 59 | }, 60 | "outputs": [], 61 | "source": [ 62 | "x = 5" 63 | ] 64 | }, 65 | { 66 | "cell_type": "code", 67 | "execution_count": 4, 68 | "metadata": { 69 | "collapsed": false 70 | }, 71 | "outputs": [ 72 | { 73 | "data": { 74 | "text/plain": [ 75 | "5" 76 | ] 77 | }, 78 | "execution_count": 4, 79 | "metadata": {}, 80 | "output_type": "execute_result" 81 | } 82 | ], 83 | "source": [ 84 | "x" 85 | ] 86 | }, 87 | { 88 | "cell_type": "code", 89 | "execution_count": null, 90 | "metadata": { 91 | "collapsed": false 92 | }, 93 | "outputs": [], 94 | "source": [ 95 | "x" 96 | ] 97 | } 98 | ], 99 | "metadata": { 100 | "kernelspec": { 101 | "display_name": "Python 2", 102 | "language": "python", 103 | "name": "python2" 104 | }, 105 | "language_info": { 106 | "codemirror_mode": { 107 | "name": "ipython", 108 | "version": 2 109 | }, 110 | "file_extension": ".py", 111 | "mimetype": "text/x-python", 112 | "name": "python", 113 | "nbconvert_exporter": "python", 114 | "pygments_lexer": "ipython2", 115 | "version": "2.7.11" 116 | } 117 | }, 118 | "nbformat": 4, 119 | "nbformat_minor": 0 120 | } 121 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/samples/ipynb_base.json: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Cool Header" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "## Some information\n", 15 | "* Item 1\n", 16 | "* Item 2\n", 17 | "* Item 3" 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": 1, 23 | "metadata": {}, 24 | "outputs": [ 25 | { 26 | "name": "stdout", 27 | "output_type": "stream", 28 | "text": [ 29 | "hi\n", 30 | "2\n" 31 | ] 32 | } 33 | ], 34 | "source": [ 35 | "print('hi')\n", 36 | "print(1+1)" 37 | ] 38 | } 39 | ], 40 | "metadata": { 41 | "kernelspec": { 42 | "display_name": "Python 3", 43 | "language": "python", 44 | "name": "python3" 45 | }, 46 | "language_info": { 47 | "codemirror_mode": { 48 | "name": "ipython", 49 | "version": 3 50 | }, 51 | "file_extension": ".py", 52 | "mimetype": "text/x-python", 53 | "name": "python", 54 | "nbconvert_exporter": "python", 55 | "pygments_lexer": "ipython3", 56 | "version": "3.7.3" 57 | } 58 | }, 59 | "nbformat": 4, 60 | "nbformat_minor": 2 61 | } -------------------------------------------------------------------------------- /jupyterlab_git/tests/samples/ipynb_nbdiff.json: -------------------------------------------------------------------------------- 1 | { 2 | "base":{ 3 | "cells":[ 4 | { 5 | "cell_type":"markdown", 6 | "metadata":{ 7 | 8 | }, 9 | "source":"## Cool Header" 10 | }, 11 | { 12 | "cell_type":"markdown", 13 | "metadata":{ 14 | 15 | }, 16 | "source":"## Some information\n* Item 1\n* Item 2\n* Item 3" 17 | }, 18 | { 19 | "cell_type":"code", 20 | "execution_count":1, 21 | "metadata":{ 22 | 23 | }, 24 | "outputs":[ 25 | { 26 | "name":"stdout", 27 | "output_type":"stream", 28 | "text":"hi\n2\n" 29 | } 30 | ], 31 | "source":"print('hi')\nprint(1+1)" 32 | } 33 | ], 34 | "metadata":{ 35 | "kernelspec":{ 36 | "display_name":"Python 3", 37 | "language":"python", 38 | "name":"python3" 39 | }, 40 | "language_info":{ 41 | "codemirror_mode":{ 42 | "name":"ipython", 43 | "version":3 44 | }, 45 | "file_extension":".py", 46 | "mimetype":"text/x-python", 47 | "name":"python", 48 | "nbconvert_exporter":"python", 49 | "pygments_lexer":"ipython3", 50 | "version":"3.7.3" 51 | } 52 | }, 53 | "nbformat":4, 54 | "nbformat_minor":2 55 | }, 56 | "diff":[ 57 | { 58 | "op":"patch", 59 | "key":"cells", 60 | "diff":[ 61 | { 62 | "op":"addrange", 63 | "key":0, 64 | "valuelist":[ 65 | { 66 | "cell_type":"code", 67 | "execution_count":1, 68 | "metadata":{ 69 | 70 | }, 71 | "outputs":[ 72 | 73 | ], 74 | "source":"def hello():\n print(\"hello!\")" 75 | } 76 | ] 77 | }, 78 | { 79 | "op":"removerange", 80 | "key":0, 81 | "length":1 82 | }, 83 | { 84 | "op":"patch", 85 | "key":1, 86 | "diff":[ 87 | { 88 | "op":"patch", 89 | "key":"source", 90 | "diff":[ 91 | { 92 | "op":"addrange", 93 | "key":3, 94 | "valuelist":[ 95 | "* Item 2.5\n" 96 | ] 97 | } 98 | ] 99 | } 100 | ] 101 | } 102 | ] 103 | }, 104 | { 105 | "op":"replace", 106 | "key":"nbformat_minor", 107 | "value":4 108 | } 109 | ] 110 | } -------------------------------------------------------------------------------- /jupyterlab_git/tests/samples/ipynb_remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "def hello():\n", 10 | " print(\"hello!\")" 11 | ] 12 | }, 13 | { 14 | "cell_type": "markdown", 15 | "metadata": {}, 16 | "source": [ 17 | "## Some information\n", 18 | "* Item 1\n", 19 | "* Item 2\n", 20 | "* Item 2.5\n", 21 | "* Item 3" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": 1, 27 | "metadata": {}, 28 | "outputs": [ 29 | { 30 | "name": "stdout", 31 | "output_type": "stream", 32 | "text": [ 33 | "hi\n", 34 | "2\n" 35 | ] 36 | } 37 | ], 38 | "source": [ 39 | "print('hi')\n", 40 | "print(1+1)" 41 | ] 42 | } 43 | ], 44 | "metadata": { 45 | "kernelspec": { 46 | "display_name": "Python 3", 47 | "language": "python", 48 | "name": "python3" 49 | }, 50 | "language_info": { 51 | "codemirror_mode": { 52 | "name": "ipython", 53 | "version": 3 54 | }, 55 | "file_extension": ".py", 56 | "mimetype": "text/x-python", 57 | "name": "python", 58 | "nbconvert_exporter": "python", 59 | "pygments_lexer": "ipython3", 60 | "version": "3.7.3" 61 | } 62 | }, 63 | "nbformat": 4, 64 | "nbformat_minor": 4 65 | } -------------------------------------------------------------------------------- /jupyterlab_git/tests/test_execute.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import patch 3 | 4 | from jupyterlab_git.git import execute, execution_lock 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_execute_waits_on_index_lock(tmp_path): 9 | lock_file = tmp_path / ".git/index.lock" 10 | lock_file.parent.mkdir(parents=True, exist_ok=True) 11 | lock_file.write_text("") 12 | 13 | async def remove_lock_file(*args): 14 | assert "unlocked" not in repr(execution_lock) # Check that the lock is working 15 | lock_file.unlink() # Raise an error for missing file 16 | 17 | with patch("tornado.gen.sleep") as sleep: 18 | sleep.side_effect = remove_lock_file # Remove the lock file instead of sleeping 19 | 20 | assert "unlock" in repr(execution_lock) 21 | cmd = ["git", "dummy"] 22 | kwargs = {"cwd": "{!s}".format(tmp_path), "timeout": 20} 23 | await execute(cmd, **kwargs) 24 | assert "unlock" in repr(execution_lock) 25 | 26 | assert not lock_file.exists() 27 | assert sleep.call_count == 1 28 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/test_ignore.py: -------------------------------------------------------------------------------- 1 | from platform import system 2 | 3 | import pytest 4 | from jupyterlab_git.git import Git 5 | 6 | 7 | @pytest.mark.parametrize("ignore_content", [None, "dummy", "dummy\n"]) 8 | @pytest.mark.asyncio 9 | async def test_ensure_gitignore(tmp_path, ignore_content): 10 | # Given 11 | ignore_file = tmp_path / ".gitignore" 12 | if ignore_content is not None: 13 | ignore_file.write_text(ignore_content) 14 | 15 | # When 16 | actual_response = await Git().ensure_gitignore(str(tmp_path)) 17 | 18 | # Then 19 | assert {"code": 0} == actual_response 20 | content = ignore_file.read_text() 21 | assert len(content) == 0 or content.endswith("\n") 22 | 23 | 24 | @pytest.mark.skipif(system() == "Windows", reason="chmod not valid on Windows") 25 | @pytest.mark.asyncio 26 | async def test_ensure_gitignore_failure(tmp_path): 27 | # Given 28 | ignore_file = tmp_path / ".gitignore" 29 | ignore_file.write_text("dummy") 30 | ignore_file.chmod(200) # Set read only to generate an error 31 | 32 | # When 33 | response = await Git().ensure_gitignore(str(tmp_path)) 34 | 35 | # Then 36 | assert response["code"] == -1 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_ignore(tmp_path): 41 | # Given 42 | ignore_file = tmp_path / ".gitignore" 43 | ignore_file.write_text("dummy") 44 | file_ignore = "to_ignore.txt" 45 | 46 | # When 47 | response = await Git().ignore(str(tmp_path), file_ignore) 48 | 49 | # Then 50 | assert {"code": 0} == response 51 | content = ignore_file.read_text() 52 | content.endswith("{}\n".format(file_ignore)) 53 | 54 | 55 | @pytest.mark.skipif(system() == "Windows", reason="chmod not valid on Windows") 56 | @pytest.mark.asyncio 57 | async def test_ignore_failure(tmp_path): 58 | # Given 59 | ignore_file = tmp_path / ".gitignore" 60 | ignore_file.write_text("dummy") 61 | ignore_file.chmod(200) # Set read only to generate an error 62 | 63 | # When 64 | response = await Git().ignore(str(tmp_path), "to_ignore.txt") 65 | 66 | # Then 67 | assert response["code"] == -1 68 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/test_integrations.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | async def test_git_show_prefix(tmp_path, jp_fetch, jp_root_dir, git_repo_factory): 9 | # Given 10 | repo = git_repo_factory(jp_root_dir) 11 | # Check git repository is in the server directory 12 | assert isinstance(repo.relative_to(jp_root_dir), Path) 13 | 14 | # When 15 | response = await jp_fetch( 16 | "git", 17 | repo.relative_to(jp_root_dir).as_posix(), 18 | "show_prefix", 19 | body="{}", 20 | method="POST", 21 | ) 22 | 23 | # Then 24 | assert response.code == 200 25 | payload = json.loads(response.body) 26 | assert payload["path"] == "" 27 | 28 | 29 | async def test_git_show_prefix_symlink( 30 | tmp_path, jp_fetch, jp_root_dir, git_repo_factory, needs_symlink 31 | ): 32 | # Given 33 | repo = git_repo_factory(tmp_path) 34 | # Check git repository is not in the server directory 35 | with pytest.raises(ValueError): 36 | not repo.relative_to(jp_root_dir) 37 | 38 | local_repo = "sym_repo" 39 | 40 | os.symlink(repo, jp_root_dir / local_repo, target_is_directory=True) 41 | 42 | assert (jp_root_dir / local_repo).exists() 43 | 44 | # When 45 | try: 46 | response = await jp_fetch( 47 | "git", 48 | local_repo, 49 | "show_prefix", 50 | body="{}", 51 | method="POST", 52 | ) 53 | except Exception as e: 54 | print(str(e)) 55 | 56 | # Then 57 | assert response.code == 200 58 | payload = json.loads(response.body) 59 | assert payload["path"] == "" 60 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/test_jupytext.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import dummy 2 | import pytest 3 | 4 | from jupyterlab_git.git import Git 5 | 6 | 7 | pytest.importorskip("jupytext") 8 | 9 | 10 | @pytest.fixture 11 | def jp_server_config(jp_server_config, tmp_path): 12 | main = tmp_path / "main" 13 | main.mkdir() 14 | second = tmp_path / "second" 15 | second.mkdir() 16 | return { 17 | "ServerApp": { 18 | "jpserver_extensions": {"jupyterlab_git": True, "jupytext": True}, 19 | }, 20 | } 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "filename, expected_content", 25 | ( 26 | ( 27 | "my/file.Rmd", 28 | """--- 29 | jupyter: 30 | jupytext: 31 | cell_markers: region,endregion 32 | formats: ipynb,.pct.py:percent,.lgt.py:light,.spx.py:sphinx,md,Rmd,.pandoc.md:pandoc 33 | text_representation: 34 | extension: .Rmd 35 | format_name: rmarkdown 36 | format_version: '1.1' 37 | jupytext_version: 1.1.0 38 | kernelspec: 39 | display_name: Python 3 40 | language: python 41 | name: python3 42 | --- 43 | 44 | # A quick insight at world population 45 | 46 | ```{python} 47 | a = 22 48 | ``` 49 | """, 50 | ), 51 | ( 52 | "my/file.md", 53 | """--- 54 | jupyter: 55 | jupytext: 56 | cell_markers: region,endregion 57 | formats: ipynb,.pct.py:percent,.lgt.py:light,.spx.py:sphinx,md,Rmd,.pandoc.md:pandoc 58 | text_representation: 59 | extension: .Rmd 60 | format_name: rmarkdown 61 | format_version: '1.1' 62 | jupytext_version: 1.1.0 63 | kernelspec: 64 | display_name: Python 3 65 | language: python 66 | name: python3 67 | --- 68 | 69 | # A quick insight at world population 70 | 71 | ```python 72 | a = 22 73 | ``` 74 | """, 75 | ), 76 | ( 77 | "my/file.myst.md", 78 | """--- 79 | jupyter: 80 | jupytext: 81 | cell_markers: region,endregion 82 | formats: ipynb,.pct.py:percent,.lgt.py:light,.spx.py:sphinx,md,Rmd,.pandoc.md:pandoc 83 | text_representation: 84 | extension: .Rmd 85 | format_name: rmarkdown 86 | format_version: '1.1' 87 | jupytext_version: 1.1.0 88 | kernelspec: 89 | display_name: Python 3 90 | language: python 91 | name: python3 92 | --- 93 | 94 | # A quick insight at world population 95 | 96 | ```{code-cell} python 97 | a = 22 98 | ``` 99 | """, 100 | ), 101 | ( 102 | "my/file.pct.py", 103 | """# --- 104 | # jupyter: 105 | # jupytext: 106 | # cell_markers: region,endregion 107 | # formats: ipynb,.pct.py:percent,.lgt.py:light,.spx.py:sphinx,md,Rmd,.pandoc.md:pandoc 108 | # text_representation: 109 | # extension: .py 110 | # format_name: percent 111 | # format_version: '1.2' 112 | # jupytext_version: 1.1.0 113 | # kernelspec: 114 | # display_name: Python 3 115 | # language: python 116 | # name: python3 117 | # --- 118 | 119 | # %% [markdown] 120 | # # A quick insight at world population 121 | 122 | # %% 123 | a = 22 124 | """, 125 | ), 126 | ), 127 | ) 128 | async def test_get_content_with_jupytext( 129 | filename, expected_content, jp_serverapp, jp_root_dir, jp_fetch 130 | ): 131 | # Given 132 | local_path = jp_root_dir / "test_path" 133 | 134 | dummy_file = local_path / filename 135 | dummy_file.parent.mkdir(parents=True) 136 | dummy_file.write_text(expected_content) 137 | 138 | manager = Git() 139 | 140 | # When 141 | content = await manager.get_content( 142 | jp_serverapp.contents_manager, str(filename), str(local_path) 143 | ) 144 | 145 | # Then 146 | assert content == expected_content 147 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import patch 3 | 4 | from packaging.version import parse 5 | 6 | from jupyterlab_git import __version__ 7 | from jupyterlab_git.handlers import NAMESPACE 8 | 9 | from .testutils import maybe_future 10 | 11 | 12 | @patch("jupyterlab_git.git.execute") 13 | async def test_git_get_settings_success(mock_execute, jp_fetch): 14 | # Given 15 | git_version = "2.10.3" 16 | jlab_version = "2.1.42-alpha.24" 17 | mock_execute.return_value = maybe_future( 18 | (0, "git version {}.os_platform.42".format(git_version), "") 19 | ) 20 | 21 | # When 22 | response = await jp_fetch( 23 | NAMESPACE, "settings", method="GET", params={"version": jlab_version} 24 | ) 25 | 26 | # Then 27 | mock_execute.assert_called_once_with( 28 | ["git", "--version"], 29 | cwd=".", 30 | timeout=20, 31 | env=None, 32 | username=None, 33 | password=None, 34 | is_binary=False, 35 | ) 36 | 37 | assert response.code == 200 38 | payload = json.loads(response.body) 39 | assert payload == { 40 | "frontendVersion": str(parse(jlab_version)), 41 | "gitVersion": git_version, 42 | "serverVersion": str(parse(__version__)), 43 | } 44 | 45 | 46 | @patch("jupyterlab_git.git.execute") 47 | async def test_git_get_settings_no_git(mock_execute, jp_fetch): 48 | # Given 49 | jlab_version = "2.1.42-alpha.24" 50 | mock_execute.side_effect = FileNotFoundError( 51 | "[Errno 2] No such file or directory: 'git'" 52 | ) 53 | 54 | # When 55 | response = await jp_fetch( 56 | NAMESPACE, "settings", method="GET", params={"version": jlab_version} 57 | ) 58 | 59 | # Then 60 | mock_execute.assert_called_once_with( 61 | ["git", "--version"], 62 | cwd=".", 63 | timeout=20, 64 | env=None, 65 | username=None, 66 | password=None, 67 | is_binary=False, 68 | ) 69 | 70 | assert response.code == 200 71 | payload = json.loads(response.body) 72 | assert payload == { 73 | "frontendVersion": str(parse(jlab_version)), 74 | "gitVersion": None, 75 | "serverVersion": str(parse(__version__)), 76 | } 77 | 78 | 79 | @patch("jupyterlab_git.git.execute") 80 | async def test_git_get_settings_no_jlab(mock_execute, jp_fetch): 81 | # Given 82 | git_version = "2.10.3" 83 | mock_execute.return_value = maybe_future( 84 | (0, "git version {}.os_platform.42".format(git_version), "") 85 | ) 86 | 87 | # When 88 | response = await jp_fetch(NAMESPACE, "settings", method="GET") 89 | 90 | # Then 91 | mock_execute.assert_called_once_with( 92 | ["git", "--version"], 93 | cwd=".", 94 | timeout=20, 95 | env=None, 96 | username=None, 97 | password=None, 98 | is_binary=False, 99 | ) 100 | 101 | assert response.code == 200 102 | payload = json.loads(response.body) 103 | assert payload == { 104 | "frontendVersion": None, 105 | "gitVersion": git_version, 106 | "serverVersion": str(parse(__version__)), 107 | } 108 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/test_single_file_log.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from jupyterlab_git.git import Git 7 | 8 | from .testutils import maybe_future 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_single_file_log(): 13 | with patch("jupyterlab_git.git.execute") as mock_execute: 14 | # Given 15 | process_output = [ 16 | "74baf6e1d18dfa004d9b9105ff86746ab78084eb", 17 | "Lazy Senior Developer", 18 | "1 hours ago", 19 | "Something", 20 | "", 21 | "0 0 test.txt\x00\x008852729159bef63d7197f8aa26355b387283cb58", 22 | "Lazy Senior Developer", 23 | "2 hours ago", 24 | "Something Else", 25 | "e6d4eed300811e886cadffb16eeed19588eb5eec", 26 | "0 1 test.txt\x00\x00d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0", 27 | "Lazy Junior Developer", 28 | "5 hours ago", 29 | "Something More", 30 | "263f762e0aad329c3c01bbd9a28f66403e6cfa5f e6d4eed300811e886cadffb16eeed19588eb5eec", 31 | "1 1 test.txt", 32 | ] 33 | 34 | mock_execute.return_value = maybe_future((0, "\n".join(process_output), "")) 35 | 36 | expected_response = { 37 | "code": 0, 38 | "commits": [ 39 | { 40 | "commit": "74baf6e1d18dfa004d9b9105ff86746ab78084eb", 41 | "author": "Lazy Senior Developer", 42 | "date": "1 hours ago", 43 | "commit_msg": "Something", 44 | "pre_commits": [], 45 | "is_binary": False, 46 | "file_path": "test.txt", 47 | }, 48 | { 49 | "commit": "8852729159bef63d7197f8aa26355b387283cb58", 50 | "author": "Lazy Senior Developer", 51 | "date": "2 hours ago", 52 | "commit_msg": "Something Else", 53 | "pre_commits": ["e6d4eed300811e886cadffb16eeed19588eb5eec"], 54 | "is_binary": False, 55 | "file_path": "test.txt", 56 | }, 57 | { 58 | "commit": "d19001d71bb928ec9ed6ae3fe1bfc474e1b771d0", 59 | "author": "Lazy Junior Developer", 60 | "date": "5 hours ago", 61 | "commit_msg": "Something More", 62 | "pre_commits": [ 63 | "263f762e0aad329c3c01bbd9a28f66403e6cfa5f", 64 | "e6d4eed300811e886cadffb16eeed19588eb5eec", 65 | ], 66 | "is_binary": False, 67 | "file_path": "test.txt", 68 | }, 69 | ], 70 | } 71 | 72 | # When 73 | actual_response = await Git().log( 74 | path=str(Path("/bin/test_curr_path")), 75 | history_count=25, 76 | follow_path="folder/test.txt", 77 | ) 78 | 79 | # Then 80 | mock_execute.assert_called_once_with( 81 | [ 82 | "git", 83 | "log", 84 | "--pretty=format:%H%n%an%n%ar%n%s%n%P", 85 | "-25", 86 | "-z", 87 | "--numstat", 88 | "--follow", 89 | "--", 90 | "folder/test.txt", 91 | ], 92 | cwd=str(Path("/bin") / "test_curr_path"), 93 | timeout=20, 94 | env=None, 95 | username=None, 96 | password=None, 97 | is_binary=False, 98 | ) 99 | 100 | assert expected_response == actual_response 101 | -------------------------------------------------------------------------------- /jupyterlab_git/tests/testutils.py: -------------------------------------------------------------------------------- 1 | """Helpers for tests""" 2 | 3 | import json 4 | 5 | try: 6 | from unittest.mock import AsyncMock # New in Python 3.8 and used by unittest.mock 7 | except ImportError: 8 | AsyncMock = None 9 | 10 | 11 | import tornado 12 | from jupyter_server.utils import ensure_async 13 | 14 | 15 | def assert_http_error(error, expected_code, expected_message=None): 16 | """Check that the error matches the expected output error.""" 17 | e = error.value 18 | if isinstance(e, tornado.web.HTTPError): 19 | assert ( 20 | expected_code == e.status_code 21 | ), f"Expected status code {expected_code} != {e.status_code}" 22 | if expected_message is not None: 23 | assert expected_message in str( 24 | e 25 | ), f"Expected error message '{expected_message}' not in '{str(e)}'" 26 | 27 | elif any( 28 | [ 29 | isinstance(e, tornado.httpclient.HTTPClientError), 30 | isinstance(e, tornado.httpclient.HTTPError), 31 | ] 32 | ): 33 | assert ( 34 | expected_code == e.code 35 | ), f"Expected status code {expected_code} != {e.code}" 36 | if expected_message: 37 | message = json.loads(e.response.body.decode())["message"] 38 | assert ( 39 | expected_message in message 40 | ), f"Expected error message '{expected_message}' not in '{message}'" 41 | 42 | 43 | class FakeContentManager: 44 | def get(self, path=None): 45 | return {"content": ""} 46 | 47 | 48 | def maybe_future(args): 49 | if AsyncMock is None: 50 | return ensure_async(args) 51 | else: 52 | return args 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyterlab_git" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.8" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 4", 14 | "Framework :: Jupyter :: JupyterLab :: Extensions", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | ] 25 | dependencies = [ 26 | "jupyter_server>=2.0.1,<3", 27 | "nbdime~=4.0.1", 28 | "nbformat", 29 | "packaging", 30 | "pexpect", 31 | "traitlets~=5.0", 32 | ] 33 | dynamic = ["version", "description", "authors", "urls", "keywords"] 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "black", 38 | "jupyterlab~=4.0", 39 | "pre-commit" 40 | ] 41 | test = [ 42 | "coverage", 43 | "pytest", 44 | "pytest-asyncio", 45 | "pytest-cov", 46 | "pytest-jupyter[server]>=0.6.0", 47 | "jupytext", 48 | ] 49 | ui-tests = [ 50 | "jupyter-archive" 51 | ] 52 | 53 | [tool.hatch.version] 54 | source = "nodejs" 55 | 56 | [tool.hatch.metadata.hooks.nodejs] 57 | fields = ["description", "authors", "urls"] 58 | 59 | [tool.hatch.build.targets.sdist] 60 | artifacts = ["jupyterlab_git/labextension"] 61 | exclude = [".github", "binder"] 62 | 63 | [tool.hatch.build.targets.wheel.shared-data] 64 | "jupyterlab_git/labextension" = "share/jupyter/labextensions/@jupyterlab/git" 65 | "install.json" = "share/jupyter/labextensions/@jupyterlab/git/install.json" 66 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 67 | 68 | [tool.hatch.build.hooks.version] 69 | path = "jupyterlab_git/_version.py" 70 | 71 | [tool.hatch.build.hooks.jupyter-builder] 72 | dependencies = ["hatch-jupyter-builder>=0.5"] 73 | build-function = "hatch_jupyter_builder.npm_builder" 74 | ensured-targets = [ 75 | "jupyterlab_git/labextension/static/style.js", 76 | "jupyterlab_git/labextension/package.json", 77 | ] 78 | skip-if-exists = ["jupyterlab_git/labextension/static/style.js"] 79 | 80 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 81 | build_cmd = "build:prod" 82 | npm = ["jlpm"] 83 | 84 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 85 | build_cmd = "install:extension" 86 | npm = ["jlpm"] 87 | source_dir = "src" 88 | build_dir = "jupyterlab_git/labextension" 89 | 90 | [tool.jupyter-releaser.options] 91 | version_cmd = "hatch version" 92 | 93 | [tool.jupyter-releaser.hooks] 94 | before-build-npm = [ 95 | "python -m pip install 'jupyterlab>=4.0.0,<5'", 96 | "jlpm", 97 | "jlpm build:prod" 98 | ] 99 | before-build-python = ["jlpm clean:all"] 100 | 101 | [tool.check-wheel-contents] 102 | ignore = ["W002"] 103 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /src/__tests__/test-components/CommitMessage.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { nullTranslator } from '@jupyterlab/translation'; 3 | import '@testing-library/jest-dom'; 4 | import { render, screen } from '@testing-library/react'; 5 | import 'jest'; 6 | import * as React from 'react'; 7 | import { 8 | CommitMessage, 9 | ICommitMessageProps 10 | } from '../../components/CommitMessage'; 11 | 12 | describe('CommitMessage', () => { 13 | const trans = nullTranslator.load('jupyterlab_git'); 14 | 15 | const defaultProps: ICommitMessageProps = { 16 | setSummary: () => {}, 17 | setDescription: () => {}, 18 | summary: '', 19 | description: '', 20 | trans: trans 21 | }; 22 | 23 | it('should set a `title` attribute on the input element to provide a commit message summary', () => { 24 | const props = defaultProps; 25 | render(); 26 | 27 | expect(screen.getAllByRole('textbox')[0].parentElement).toHaveAttribute( 28 | 'title' 29 | ); 30 | }); 31 | 32 | it('should display placeholder text for the commit message description', () => { 33 | const props = defaultProps; 34 | render(); 35 | 36 | expect(screen.getAllByRole('textbox')[1]).toHaveAttribute( 37 | 'placeholder', 38 | 'Description (optional)' 39 | ); 40 | }); 41 | 42 | it('should set a `title` attribute on the input element to provide a commit message description', () => { 43 | const props = defaultProps; 44 | render(); 45 | 46 | expect(screen.getAllByRole('textbox')[1].parentElement).toHaveAttribute( 47 | 'title' 48 | ); 49 | }); 50 | 51 | it('should disable summary input if disabled is true', () => { 52 | const props = { ...defaultProps, disabled: true }; 53 | render(); 54 | 55 | expect(screen.getAllByRole('textbox')[0]).toHaveAttribute('disabled'); 56 | }); 57 | 58 | it('should disable description input if disabled is true', () => { 59 | const props = { ...defaultProps, disabled: true }; 60 | render(); 61 | 62 | expect(screen.getAllByRole('textbox')[1]).toHaveAttribute('disabled'); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/__tests__/test-components/DiffModel.spec.tsx: -------------------------------------------------------------------------------- 1 | import { testEmission } from '@jupyterlab/testutils'; 2 | import 'jest'; 3 | import { DiffModel } from '../../components/diff/model'; 4 | import { Git } from '../../tokens'; 5 | 6 | describe('DiffModel', () => { 7 | let model: DiffModel; 8 | 9 | /** 10 | * Helper to test changed signal. 11 | */ 12 | const testChangedSignal = (type: Git.Diff.IModelChange['type']) => 13 | testEmission(model.changed, { 14 | test: (_, change) => { 15 | expect(change.type).toEqual(type); 16 | } 17 | }); 18 | 19 | beforeEach(() => { 20 | model = new DiffModel({ 21 | filename: 'KrabbyPattySecretFormula.txt', 22 | repositoryPath: '/', 23 | challenger: { 24 | content: () => Promise.resolve('content'), 25 | label: 'challenger', 26 | source: 'challenger' 27 | }, 28 | reference: { 29 | content: () => Promise.resolve('content'), 30 | label: 'reference', 31 | source: 'reference' 32 | } 33 | }); 34 | }); 35 | 36 | it('should emit a signal if reference changes', async () => { 37 | const testReference = testChangedSignal('reference'); 38 | 39 | model.reference = { 40 | content: () => Promise.resolve('content2'), 41 | label: 'reference2', 42 | source: 'reference2' 43 | }; 44 | 45 | await testReference; 46 | }); 47 | 48 | it('should emit a signal if challenger changes', async () => { 49 | const testChallenger = testChangedSignal('challenger'); 50 | 51 | model.challenger = { 52 | content: () => Promise.resolve('content2'), 53 | label: 'challenger2', 54 | source: 'challenger2' 55 | }; 56 | 57 | await testChallenger; 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/__tests__/test-components/FileItem.spec.tsx: -------------------------------------------------------------------------------- 1 | import { nullTranslator } from '@jupyterlab/translation'; 2 | import '@testing-library/jest-dom'; 3 | import { render, screen } from '@testing-library/react'; 4 | import 'jest'; 5 | import * as React from 'react'; 6 | import { FileItem, IFileItemProps } from '../../components/FileItem'; 7 | 8 | describe('FileItem', () => { 9 | const trans = nullTranslator.load('jupyterlab_git'); 10 | 11 | const props: IFileItemProps = { 12 | contextMenu: () => {}, 13 | file: { 14 | x: '', 15 | y: 'M', 16 | to: 'some/file/path/file-name', 17 | from: '', 18 | is_binary: null, 19 | status: null 20 | }, 21 | model: null as any, 22 | onDoubleClick: () => {}, 23 | selected: false, 24 | setSelection: file => {}, 25 | style: {}, 26 | trans 27 | }; 28 | 29 | describe('#render()', () => { 30 | it('should display the full path on hover', () => { 31 | render(); 32 | expect( 33 | screen.getAllByTitle('some/file/path/file-name • Modified') 34 | ).toHaveLength(1); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__tests__/test-components/NotebookDiff.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import { DiffModel } from '../../components/diff/model'; 3 | import { NotebookDiff, ROOT_CLASS } from '../../components/diff/NotebookDiff'; 4 | import { requestAPI } from '../../git'; 5 | import { Git } from '../../tokens'; 6 | import * as diffResponse from './data/nbDiffResponse.json'; 7 | import { RenderMimeRegistry } from '@jupyterlab/rendermime'; 8 | 9 | jest.mock('../../git'); 10 | 11 | describe('NotebookDiff', () => { 12 | it('should render notebook diff in success case', async () => { 13 | // Given 14 | const model = new DiffModel({ 15 | challenger: { 16 | content: () => Promise.resolve('challenger'), 17 | label: 'WORKING', 18 | source: Git.Diff.SpecialRef.WORKING 19 | }, 20 | reference: { 21 | content: () => Promise.resolve('reference'), 22 | label: '83baee', 23 | source: '83baee' 24 | }, 25 | filename: 'to/File.ipynb', 26 | repositoryPath: 'path' 27 | }); 28 | 29 | (requestAPI as jest.Mock).mockResolvedValueOnce(diffResponse); 30 | 31 | // When 32 | const widget = new NotebookDiff(model, new RenderMimeRegistry()); 33 | await widget.ready; 34 | 35 | // Then 36 | let resolveTest: (value?: any) => void; 37 | const terminateTest = new Promise(resolve => { 38 | resolveTest = resolve; 39 | }); 40 | setTimeout(() => { 41 | expect(requestAPI).toHaveBeenCalled(); 42 | expect(requestAPI).toBeCalledWith('diffnotebook', 'POST', { 43 | currentContent: 'challenger', 44 | previousContent: 'reference' 45 | }); 46 | expect(widget.node.querySelectorAll('.jp-git-diff-error')).toHaveLength( 47 | 0 48 | ); 49 | expect(widget.node.querySelectorAll(`.${ROOT_CLASS}`)).toHaveLength(1); 50 | expect(widget.node.querySelectorAll('.jp-Notebook-diff')).toHaveLength(1); 51 | resolveTest(); 52 | }, 1); 53 | await terminateTest; 54 | }); 55 | 56 | it('should render error in if API response is failed', async () => { 57 | // Given 58 | const model = new DiffModel({ 59 | challenger: { 60 | content: () => Promise.resolve('challenger'), 61 | label: 'WORKING', 62 | source: Git.Diff.SpecialRef.WORKING 63 | }, 64 | reference: { 65 | content: () => Promise.resolve('reference'), 66 | label: '83baee', 67 | source: '83baee' 68 | }, 69 | filename: 'to/File.ipynb', 70 | repositoryPath: 'path' 71 | }); 72 | 73 | (requestAPI as jest.Mock).mockRejectedValueOnce( 74 | new Git.GitResponseError( 75 | new Response('', { status: 401 }), 76 | 'TEST_ERROR_MESSAGE' 77 | ) 78 | ); 79 | 80 | // When 81 | const widget = new NotebookDiff(model, new RenderMimeRegistry()); 82 | await widget.ready; 83 | 84 | // Then 85 | let resolveTest: (value?: any) => void; 86 | const terminateTest = new Promise(resolve => { 87 | resolveTest = resolve; 88 | }); 89 | setTimeout(() => { 90 | expect(requestAPI).toHaveBeenCalled(); 91 | expect(requestAPI).toBeCalledWith('diffnotebook', 'POST', { 92 | currentContent: 'challenger', 93 | previousContent: 'reference' 94 | }); 95 | expect( 96 | widget.node.querySelector('.jp-git-diff-error')!.innerHTML 97 | ).toContain('TEST_ERROR_MESSAGE'); 98 | resolveTest(); 99 | }, 1); 100 | await terminateTest; 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/__tests__/test-components/PlainTextDiff.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import { DiffModel } from '../../components/diff/model'; 3 | import { PlainTextDiff } from '../../components/diff/PlainTextDiff'; 4 | import { Git } from '../../tokens'; 5 | 6 | jest.mock('../../git'); 7 | 8 | describe('PlainTextDiff', () => { 9 | it('should render file diff', async () => { 10 | // Given 11 | const model = new DiffModel({ 12 | challenger: { 13 | content: () => Promise.resolve('challenger'), 14 | label: 'WORKING', 15 | source: Git.Diff.SpecialRef.WORKING 16 | }, 17 | reference: { 18 | content: () => Promise.resolve('reference'), 19 | label: '83baee', 20 | source: '83baee' 21 | }, 22 | filename: 'to/File.py', 23 | repositoryPath: 'path' 24 | }); 25 | 26 | // When 27 | const widget = new PlainTextDiff({ model }); 28 | await widget.ready; 29 | 30 | // Then 31 | let resolveTest: (value?: any) => void; 32 | const terminateTest = new Promise(resolve => { 33 | resolveTest = resolve; 34 | }); 35 | setTimeout(() => { 36 | expect(widget.node.querySelectorAll('.jp-git-diff-error')).toHaveLength( 37 | 0 38 | ); 39 | resolveTest(); 40 | }, 0); 41 | await terminateTest; 42 | }); 43 | 44 | it('should render error in if API response is failed', async () => { 45 | // Given 46 | const model = new DiffModel({ 47 | challenger: { 48 | content: () => Promise.reject('TEST_ERROR_MESSAGE'), 49 | label: 'WORKING', 50 | source: Git.Diff.SpecialRef.WORKING 51 | }, 52 | reference: { 53 | content: () => Promise.resolve('reference'), 54 | label: '83baee', 55 | source: '83baee' 56 | }, 57 | filename: 'to/File.py', 58 | repositoryPath: 'path' 59 | }); 60 | 61 | // When 62 | const widget = new PlainTextDiff({ model }); 63 | await widget.ready; 64 | 65 | // Then 66 | let resolveTest: (value?: any) => void; 67 | const terminateTest = new Promise(resolve => { 68 | resolveTest = resolve; 69 | }); 70 | setTimeout(() => { 71 | expect( 72 | widget.node.querySelector('.jp-git-diff-error')!.innerHTML 73 | ).toContain('TEST_ERROR_MESSAGE'); 74 | resolveTest(); 75 | }, 0); 76 | await terminateTest; 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/__tests__/test-components/SubModuleMenu.spec.tsx: -------------------------------------------------------------------------------- 1 | import { nullTranslator } from '@jupyterlab/translation'; 2 | import '@testing-library/jest-dom'; 3 | import { render, screen } from '@testing-library/react'; 4 | import 'jest'; 5 | import * as React from 'react'; 6 | import { 7 | ISubmoduleMenuProps, 8 | SubmoduleMenu 9 | } from '../../components/SubmoduleMenu'; 10 | import { GitExtension } from '../../model'; 11 | import { IGitExtension } from '../../tokens'; 12 | import { DEFAULT_REPOSITORY_PATH } from '../utils'; 13 | 14 | jest.mock('../../git'); 15 | jest.mock('@jupyterlab/apputils'); 16 | 17 | const SUBMODULES = [ 18 | { 19 | name: 'cli/bench' 20 | }, 21 | { 22 | name: 'test/util' 23 | } 24 | ]; 25 | 26 | async function createModel() { 27 | const model = new GitExtension(); 28 | model.pathRepository = DEFAULT_REPOSITORY_PATH; 29 | 30 | await model.ready; 31 | return model; 32 | } 33 | 34 | describe('Submodule Menu', () => { 35 | let model: GitExtension; 36 | const trans = nullTranslator.load('jupyterlab_git'); 37 | 38 | beforeEach(async () => { 39 | jest.restoreAllMocks(); 40 | 41 | model = await createModel(); 42 | }); 43 | 44 | function createProps( 45 | props?: Partial 46 | ): ISubmoduleMenuProps { 47 | return { 48 | model: model as IGitExtension, 49 | trans: trans, 50 | submodules: SUBMODULES, 51 | ...props 52 | }; 53 | } 54 | 55 | describe('render', () => { 56 | it('should display a list of submodules', () => { 57 | render(); 58 | 59 | const submodules = SUBMODULES; 60 | expect(screen.getAllByRole('listitem').length).toEqual(submodules.length); 61 | 62 | // Should contain the submodule names... 63 | for (let i = 0; i < submodules.length; i++) { 64 | expect( 65 | screen.getByText(submodules[i].name, { exact: true }) 66 | ).toBeDefined(); 67 | } 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import { ReadonlyJSONObject } from '@lumino/coreutils'; 2 | import { Git } from '../tokens'; 3 | 4 | export interface IMockedResponse { 5 | // Response body 6 | body?: (body: any) => ReadonlyJSONObject | null; 7 | // Response status code 8 | status?: number; 9 | } 10 | 11 | export interface IMockedResponses { 12 | // Folder path in URI; default = DEFAULT_REPOSITORY_PATH 13 | path?: string | null; 14 | // Endpoint 15 | responses?: { 16 | [endpoint: string]: IMockedResponse; 17 | }; 18 | } 19 | 20 | export const DEFAULT_REPOSITORY_PATH = 'path/to/repo'; 21 | 22 | export const defaultMockedResponses: { 23 | [endpoint: string]: IMockedResponse; 24 | } = { 25 | branch: { 26 | body: () => ({ 27 | code: 0, 28 | branches: [], 29 | current_branch: { name: '' } 30 | }) 31 | }, 32 | changed_files: { 33 | body: () => ({ 34 | code: 0, 35 | files: [] 36 | }) 37 | }, 38 | show_prefix: { 39 | body: () => ({ 40 | code: 0, 41 | path: '' 42 | }) 43 | }, 44 | stash: { 45 | body: () => ({ 46 | code: 0, 47 | message: '', 48 | command: '' 49 | }) 50 | }, 51 | status: { 52 | body: () => ({ 53 | code: 0, 54 | files: [] 55 | }) 56 | }, 57 | tags: { 58 | body: () => ({ 59 | code: 0, 60 | tags: [] 61 | }) 62 | } 63 | }; 64 | 65 | export function mockedRequestAPI( 66 | mockedResponses?: IMockedResponses 67 | ): ( 68 | endPoint?: string, 69 | method?: string, 70 | body?: ReadonlyJSONObject | null, 71 | namespace?: string 72 | ) => Promise { 73 | const mockedImplementation = ( 74 | url?: string, 75 | method?: string, 76 | body?: ReadonlyJSONObject | null, 77 | namespace?: string 78 | ) => { 79 | mockedResponses = mockedResponses ?? {}; 80 | const path = mockedResponses.path ?? DEFAULT_REPOSITORY_PATH; 81 | const responses = mockedResponses.responses ?? defaultMockedResponses; 82 | url = (url ?? '').replace(new RegExp(`^${path}/`), ''); // Remove path + '/' 83 | const reply = responses[url + method] ?? responses[url]; 84 | if (reply) { 85 | if (reply.status) { 86 | throw new Git.GitResponseError( 87 | new Response(null, { 88 | status: reply.status 89 | }), 90 | '', 91 | '', 92 | reply.body ? reply.body(body) : {} 93 | ); 94 | } else { 95 | return Promise.resolve(reply.body?.(body)); 96 | } 97 | } else { 98 | throw new Git.GitResponseError( 99 | new Response(`{"message": "No mock implementation for ${url}."}`, { 100 | status: 404 101 | }) 102 | ); 103 | } 104 | }; 105 | return mockedImplementation; 106 | } 107 | -------------------------------------------------------------------------------- /src/cancelledError.ts: -------------------------------------------------------------------------------- 1 | export class CancelledError extends Error { 2 | constructor(...params: any) { 3 | super(...params); 4 | this.name = 'CancelledError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/components/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | import * as React from 'react'; 3 | import { classes } from 'typestyle'; 4 | import { actionButtonStyle } from '../style/ActionButtonStyle'; 5 | 6 | /** 7 | * Action button properties interface 8 | */ 9 | export interface IActionButtonProps { 10 | /** 11 | * Customize class name 12 | */ 13 | className?: string; 14 | /** 15 | * Is disabled? 16 | */ 17 | disabled?: boolean; 18 | /** 19 | * Icon 20 | */ 21 | icon: LabIcon; 22 | /** 23 | * Button title 24 | */ 25 | title: string; 26 | /** 27 | * On-click event handler 28 | */ 29 | onClick?: (event?: React.MouseEvent) => void; 30 | } 31 | 32 | /** 33 | * Action button component 34 | * 35 | * @param props Component properties 36 | */ 37 | export const ActionButton: React.FunctionComponent = ( 38 | props: IActionButtonProps 39 | ) => { 40 | const { disabled, className, title, onClick, icon } = props; 41 | return ( 42 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/FilePath.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 2 | import { fileIcon } from '@jupyterlab/ui-components'; 3 | import * as React from 'react'; 4 | import { 5 | fileIconStyle, 6 | fileLabelStyle, 7 | folderLabelStyle 8 | } from '../style/FilePathStyle'; 9 | import { extractFilename } from '../utils'; 10 | 11 | /** 12 | * FilePath component properties 13 | */ 14 | export interface IFilePathProps { 15 | /** 16 | * File path 17 | */ 18 | filepath: string; 19 | /** 20 | * File type 21 | */ 22 | filetype?: DocumentRegistry.IFileType; 23 | } 24 | 25 | export const FilePath: React.FunctionComponent = ( 26 | props: IFilePathProps 27 | ) => { 28 | const filename = extractFilename(props.filepath); 29 | const folder = props.filepath 30 | .slice(0, props.filepath.length - filename.length) 31 | .replace(/^\/|\/$/g, ''); // Remove leading and trailing '/' 32 | 33 | const icon = props.filetype?.icon || fileIcon; 34 | 35 | return ( 36 | 37 | 42 | 43 | {filename} 44 | {folder} 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/GitStage.tsx: -------------------------------------------------------------------------------- 1 | import { caretDownIcon, caretRightIcon } from '@jupyterlab/ui-components'; 2 | import * as React from 'react'; 3 | import { FixedSizeList, ListChildComponentProps } from 'react-window'; 4 | import { 5 | changeStageButtonStyle, 6 | sectionAreaStyle, 7 | sectionFileContainerStyle, 8 | sectionHeaderLabelStyle, 9 | sectionHeaderSizeStyle 10 | } from '../style/GitStageStyle'; 11 | import { Git } from '../tokens'; 12 | 13 | const HEADER_HEIGHT = 34; 14 | const ITEM_HEIGHT = 25; 15 | 16 | /** 17 | * Git stage component properties 18 | */ 19 | export interface IGitStageProps { 20 | /** 21 | * Actions component to display at the far right of the stage 22 | */ 23 | actions?: React.ReactElement; 24 | /** 25 | * Is this group collapsible 26 | */ 27 | collapsible?: boolean; 28 | /** 29 | * Files in the group 30 | */ 31 | files: Git.IStatusFile[]; 32 | /** 33 | * Group title 34 | */ 35 | heading: string; 36 | /** 37 | * HTML element height 38 | */ 39 | height: number; 40 | /** 41 | * Row renderer 42 | */ 43 | rowRenderer: (props: ListChildComponentProps) => JSX.Element; 44 | /** 45 | * Optional select all element 46 | */ 47 | selectAllButton?: React.ReactElement; 48 | } 49 | 50 | export const GitStage: React.FunctionComponent = ( 51 | props: IGitStageProps 52 | ) => { 53 | const [showFiles, setShowFiles] = React.useState(true); 54 | const nFiles = props.files.length; 55 | 56 | return ( 57 |
58 |
{ 61 | if (props.collapsible && nFiles > 0) { 62 | setShowFiles(!showFiles); 63 | } 64 | }} 65 | > 66 | {props.selectAllButton && props.selectAllButton} 67 | {props.collapsible && ( 68 | 75 | )} 76 | {props.heading} 77 | {props.actions} 78 | ({nFiles}) 79 |
80 | {showFiles && nFiles > 0 && ( 81 | data[index].to} 89 | itemSize={ITEM_HEIGHT} 90 | style={{ overflowX: 'hidden' }} 91 | width={'auto'} 92 | > 93 | {props.rowRenderer} 94 | 95 | )} 96 |
97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /src/components/SelectAllButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /** 4 | * Action button properties interface 5 | */ 6 | export interface ISelectAllButtonProps { 7 | /** 8 | * Customize class name 9 | */ 10 | className?: string; 11 | /** 12 | * On-click event handler 13 | */ 14 | onChange?: (event?: React.ChangeEvent) => void; 15 | 16 | /** 17 | * Whether the checkbox is checked 18 | */ 19 | checked: boolean; 20 | } 21 | 22 | /** 23 | * Action button component 24 | * 25 | * @param props Component properties 26 | */ 27 | export const SelectAllButton: React.FunctionComponent = ( 28 | props: ISelectAllButtonProps 29 | ) => { 30 | const { className, onChange, checked } = props; 31 | return ( 32 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/SubmoduleMenu.tsx: -------------------------------------------------------------------------------- 1 | import { TranslationBundle } from '@jupyterlab/translation'; 2 | import ListItem from '@mui/material/ListItem'; 3 | import * as React from 'react'; 4 | import { FixedSizeList, ListChildComponentProps } from 'react-window'; 5 | import { 6 | listItemClass, 7 | listItemIconClass, 8 | nameClass, 9 | wrapperClass 10 | } from '../style/BranchMenu'; 11 | import { submoduleHeaderStyle } from '../style/SubmoduleMenuStyle'; 12 | import { desktopIcon } from '../style/icons'; 13 | import { Git, IGitExtension } from '../tokens'; 14 | 15 | const ITEM_HEIGHT = 24.8; // HTML element height for a single item 16 | const MIN_HEIGHT = 150; // Minimal HTML element height for the list 17 | const MAX_HEIGHT = 400; // Maximal HTML element height for the list 18 | 19 | /** 20 | * Interface describing component properties. 21 | */ 22 | export interface ISubmoduleMenuProps { 23 | /** 24 | * Git extension data model. 25 | */ 26 | model: IGitExtension; 27 | 28 | /** 29 | * The list of submodules in the repo 30 | */ 31 | submodules: Git.ISubmodule[]; 32 | 33 | /** 34 | * The application language translator. 35 | */ 36 | trans: TranslationBundle; 37 | } 38 | 39 | /** 40 | * Interface describing component state. 41 | */ 42 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 43 | export interface ISubmoduleMenuState {} 44 | 45 | /** 46 | * React component for rendering a submodule menu. 47 | */ 48 | export class SubmoduleMenu extends React.Component< 49 | ISubmoduleMenuProps, 50 | ISubmoduleMenuState 51 | > { 52 | /** 53 | * Returns a React component for rendering a submodule menu. 54 | * 55 | * @param props - component properties 56 | * @returns React component 57 | */ 58 | constructor(props: ISubmoduleMenuProps) { 59 | super(props); 60 | } 61 | 62 | /** 63 | * Renders the component. 64 | * 65 | * @returns React element 66 | */ 67 | render(): React.ReactElement { 68 | return
{this._renderSubmoduleList()}
; 69 | } 70 | 71 | /** 72 | * Renders list of submodules. 73 | * 74 | * @returns React element 75 | */ 76 | private _renderSubmoduleList(): React.ReactElement { 77 | const submodules = this.props.submodules; 78 | 79 | return ( 80 | <> 81 |
Submodules
82 | data[index].name} 90 | itemSize={ITEM_HEIGHT} 91 | style={{ 92 | overflowX: 'hidden', 93 | paddingTop: 0, 94 | paddingBottom: 0 95 | }} 96 | width={'auto'} 97 | > 98 | {this._renderItem} 99 | 100 | 101 | ); 102 | } 103 | 104 | /** 105 | * Renders a menu item. 106 | * 107 | * @param props Row properties 108 | * @returns React element 109 | */ 110 | private _renderItem = (props: ListChildComponentProps): JSX.Element => { 111 | const { data, index, style } = props; 112 | const submodule = data[index] as Git.ISubmodule; 113 | 114 | return ( 115 | 121 | 122 | {submodule.name} 123 | 124 | ); 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /src/components/WarningBox.tsx: -------------------------------------------------------------------------------- 1 | import CardContent from '@mui/material/CardContent'; 2 | import Card from '@mui/material/Card'; 3 | import CardHeader from '@mui/material/CardHeader'; 4 | import * as React from 'react'; 5 | import { classes } from 'typestyle'; 6 | import { 7 | commitRoot, 8 | dirtyStagedFilesWarningBoxClass, 9 | dirtyStagedFilesWarningBoxContentClass, 10 | dirtyStagedFilesWarningBoxHeaderClass 11 | } from '../style/CommitBox'; 12 | 13 | /** 14 | * Interface describing the properties of the warning box component. 15 | */ 16 | export interface IWarningBoxProps { 17 | /** 18 | * The warning box's header icon. 19 | */ 20 | headerIcon?: JSX.Element; 21 | /** 22 | * The warning box's header text. 23 | */ 24 | title: string; 25 | /** 26 | * The warning box's content text. 27 | */ 28 | content: string; 29 | } 30 | 31 | /** 32 | * Warning box component. 33 | */ 34 | export function WarningBox(props: IWarningBoxProps): JSX.Element { 35 | return ( 36 | 42 | 48 | 49 | {props.content} 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/diff/PreviewMainAreaWidget.ts: -------------------------------------------------------------------------------- 1 | import { MainAreaWidget } from '@jupyterlab/apputils'; 2 | import { Message } from '@lumino/messaging'; 3 | import { Panel, TabBar, Widget } from '@lumino/widgets'; 4 | 5 | export class PreviewMainAreaWidget< 6 | T extends Widget = Widget 7 | > extends MainAreaWidget { 8 | /** 9 | * Handle on the preview widget 10 | */ 11 | protected static previewWidget: PreviewMainAreaWidget | null = null; 12 | 13 | constructor(options: MainAreaWidget.IOptions & { isPreview?: boolean }) { 14 | super(options); 15 | 16 | if (options.isPreview ?? true) { 17 | PreviewMainAreaWidget.disposePreviewWidget( 18 | PreviewMainAreaWidget.previewWidget! 19 | ); 20 | PreviewMainAreaWidget.previewWidget = this; 21 | } 22 | } 23 | 24 | /** 25 | * Dispose screen as a preview screen 26 | */ 27 | static disposePreviewWidget(isPreview: PreviewMainAreaWidget): void { 28 | return isPreview && PreviewMainAreaWidget.previewWidget?.dispose(); 29 | } 30 | 31 | /** 32 | * Pin the preview screen if user clicks on tab title 33 | */ 34 | static pinWidget( 35 | tabPosition: number, 36 | tabBar: TabBar, 37 | diffWidget: PreviewMainAreaWidget 38 | ): void { 39 | // We need to wait for the tab node to be inserted in the DOM 40 | setTimeout(() => { 41 | // Get the most recent tab opened 42 | const tab = 43 | tabPosition >= 0 ? tabBar.contentNode.children[tabPosition] : null; 44 | const tabTitle = tab?.querySelector('.lm-TabBar-tabLabel'); 45 | 46 | if (!tabTitle) { 47 | return; 48 | } 49 | 50 | tabTitle.classList.add('jp-git-tab-mod-preview'); 51 | 52 | const onClick = () => { 53 | tabTitle.classList.remove('jp-git-tab-mod-preview'); 54 | tabTitle.removeEventListener('click', onClick, true); 55 | if (PreviewMainAreaWidget.previewWidget === diffWidget) { 56 | PreviewMainAreaWidget.previewWidget = null; 57 | } 58 | }; 59 | 60 | tabTitle.addEventListener('click', onClick, true); 61 | diffWidget.disposed.connect(() => { 62 | tabTitle.removeEventListener('click', onClick, true); 63 | }); 64 | }, 0); 65 | } 66 | 67 | /** 68 | * Callback just after the widget is attached to the DOM 69 | */ 70 | protected onAfterAttach(msg: Message): void { 71 | super.onAfterAttach(msg); 72 | this.node.addEventListener('click', this._onClick.bind(this), false); 73 | } 74 | 75 | /** 76 | * Callback just before the widget is detached from the DOM 77 | */ 78 | protected onBeforeDetach(msg: Message): void { 79 | this.node.removeEventListener('click', this._onClick.bind(this), false); 80 | super.onBeforeAttach(msg); 81 | } 82 | 83 | /** 84 | * Callback on click event in capture phase 85 | */ 86 | _onClick(): void { 87 | PreviewMainAreaWidget.previewWidget = null; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/components/diff/model.ts: -------------------------------------------------------------------------------- 1 | import { IDisposable } from '@lumino/disposable'; 2 | import { ISignal, Signal } from '@lumino/signaling'; 3 | import { Git } from '../../tokens'; 4 | 5 | /** 6 | * Base DiffModel class 7 | */ 8 | export class DiffModel implements IDisposable, Git.Diff.IModel { 9 | constructor(props: Omit) { 10 | this._challenger = props.challenger; 11 | this._filename = props.filename; 12 | this._reference = props.reference; 13 | this._repositoryPath = props.repositoryPath; 14 | this._base = props.base; 15 | 16 | this._changed = new Signal(this); 17 | } 18 | 19 | /** 20 | * A signal emitted when the model changed. 21 | * 22 | * Note: The signal is emitted for any set on reference or 23 | * on challenger change except for the content; i.e. the content 24 | * is not fetch to check if it changed. 25 | */ 26 | get changed(): ISignal { 27 | return this._changed; 28 | } 29 | 30 | /** 31 | * Helper to compare diff contents. 32 | */ 33 | private _didContentChange( 34 | a: Git.Diff.IContent, 35 | b: Git.Diff.IContent 36 | ): boolean { 37 | return ( 38 | a.label !== b.label || a.source !== b.source || a.updateAt !== b.updateAt 39 | ); 40 | } 41 | 42 | /** 43 | * Challenger description 44 | */ 45 | get challenger(): Git.Diff.IContent { 46 | return this._challenger; 47 | } 48 | set challenger(v: Git.Diff.IContent) { 49 | const emitSignal = this._didContentChange(this._challenger, v); 50 | 51 | if (emitSignal) { 52 | this._challenger = v; 53 | this._changed.emit({ type: 'challenger' }); 54 | } 55 | } 56 | 57 | /** 58 | * File to be compared 59 | * 60 | * Note: This path is relative to the repository path 61 | */ 62 | get filename(): string { 63 | return this._filename; 64 | } 65 | 66 | /** 67 | * Reference description 68 | */ 69 | get reference(): Git.Diff.IContent { 70 | return this._reference; 71 | } 72 | set reference(v: Git.Diff.IContent) { 73 | const emitSignal = this._didContentChange(this._reference, v); 74 | 75 | if (emitSignal) { 76 | this._reference = v; 77 | this._changed.emit({ type: 'reference' }); 78 | } 79 | } 80 | 81 | /** 82 | * Git repository path 83 | * 84 | * Note: This path is relative to the server root 85 | */ 86 | get repositoryPath(): string | undefined { 87 | return this._repositoryPath; 88 | } 89 | 90 | /** 91 | * Base description 92 | * 93 | * Note: The base diff content is only provided during 94 | * merge conflicts (three-way diff). 95 | */ 96 | get base(): Git.Diff.IContent | undefined { 97 | return this._base; 98 | } 99 | 100 | /** 101 | * Helper to check if the file has conflicts. 102 | */ 103 | get hasConflict(): boolean { 104 | return this._base !== undefined; 105 | } 106 | 107 | /** 108 | * Boolean indicating whether the model has been disposed. 109 | */ 110 | get isDisposed(): boolean { 111 | return this._isDisposed; 112 | } 113 | 114 | /** 115 | * Dispose of the model. 116 | */ 117 | dispose(): void { 118 | if (this.isDisposed) { 119 | return; 120 | } 121 | this._isDisposed = true; 122 | Signal.clearData(this); 123 | } 124 | 125 | protected _reference: Git.Diff.IContent; 126 | protected _challenger: Git.Diff.IContent; 127 | protected _base?: Git.Diff.IContent; 128 | 129 | private _changed: Signal; 130 | private _isDisposed = false; 131 | private _filename: string; 132 | private _repositoryPath: string | undefined; 133 | } 134 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | import { ServerConnection } from '@jupyterlab/services'; 3 | import { ReadonlyJSONObject } from '@lumino/coreutils'; 4 | import { Git } from './tokens'; 5 | 6 | /** 7 | * Array of Git Auth Error Messages 8 | */ 9 | export const AUTH_ERROR_MESSAGES = [ 10 | 'Invalid username or password', 11 | 'could not read Username', 12 | 'could not read Password', 13 | 'Authentication error' 14 | ]; 15 | 16 | /** 17 | * Call the API extension 18 | * 19 | * @param endPoint API REST end point for the extension; default '' 20 | * @param method HTML method; default 'GET' 21 | * @param body JSON object to be passed as body or null; default null 22 | * @param namespace API namespace; default 'git' 23 | * @returns The response body interpreted as JSON 24 | * 25 | * @throws {Git.GitResponseError} If the server response is not ok 26 | * @throws {ServerConnection.NetworkError} If the request cannot be made 27 | */ 28 | export async function requestAPI( 29 | endPoint = '', 30 | method = 'GET', 31 | body: Partial | null = null, 32 | namespace = 'git' 33 | ): Promise { 34 | // Make request to Jupyter API 35 | const settings = ServerConnection.makeSettings(); 36 | const requestUrl = URLExt.join( 37 | settings.baseUrl, 38 | namespace, // API Namespace 39 | endPoint 40 | ); 41 | 42 | const init: RequestInit = { 43 | method, 44 | body: body ? JSON.stringify(body) : undefined 45 | }; 46 | 47 | let response: Response; 48 | try { 49 | response = await ServerConnection.makeRequest(requestUrl, init, settings); 50 | } catch (error: any) { 51 | throw new ServerConnection.NetworkError(error); 52 | } 53 | 54 | let data: any = await response.text(); 55 | let isJSON = false; 56 | if (data.length > 0) { 57 | try { 58 | data = JSON.parse(data); 59 | isJSON = true; 60 | } catch (error) { 61 | console.log('Not a JSON response body.', response); 62 | } 63 | } 64 | 65 | if (!response.ok) { 66 | if (isJSON) { 67 | const { message, traceback, ...json } = data; 68 | throw new Git.GitResponseError( 69 | response, 70 | message || 71 | `Invalid response: ${response.status} ${response.statusText}`, 72 | traceback || '', 73 | json 74 | ); 75 | } else { 76 | throw new Git.GitResponseError(response, data); 77 | } 78 | } 79 | 80 | return data; 81 | } 82 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | 3 | import { ServerConnection } from '@jupyterlab/services'; 4 | 5 | /** 6 | * Call the API extension 7 | * 8 | * @param endPoint API REST end point for the extension 9 | * @param init Initial values for the request 10 | * @returns The response body interpreted as JSON 11 | */ 12 | export async function requestAPI( 13 | endPoint = '', 14 | init: RequestInit = {} 15 | ): Promise { 16 | // Make request to Jupyter API 17 | const settings = ServerConnection.makeSettings(); 18 | const requestUrl = URLExt.join( 19 | settings.baseUrl, 20 | 'jupyterlab-git', // API Namespace 21 | endPoint 22 | ); 23 | 24 | let response: Response; 25 | try { 26 | response = await ServerConnection.makeRequest(requestUrl, init, settings); 27 | } catch (error) { 28 | throw new ServerConnection.NetworkError(error as any); 29 | } 30 | 31 | let data: any = await response.text(); 32 | 33 | if (data.length > 0) { 34 | try { 35 | data = JSON.parse(data); 36 | } catch (error) { 37 | console.log('Not a JSON response body.', response); 38 | } 39 | } 40 | 41 | if (!response.ok) { 42 | throw new ServerConnection.ResponseError(response, data.message || data); 43 | } 44 | 45 | return data; 46 | } 47 | -------------------------------------------------------------------------------- /src/notifications.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Notification, showErrorMessage } from '@jupyterlab/apputils'; 2 | import { TranslationBundle } from '@jupyterlab/translation'; 3 | import * as React from 'react'; 4 | 5 | /** 6 | * Build notification options to display in a dialog the detailed error. 7 | * 8 | * @param error Error object to display 9 | * @param trans Extension translation object 10 | * @returns Notification option to display the full error 11 | */ 12 | export function showError( 13 | error: Error, 14 | trans: TranslationBundle 15 | ): Notification.IOptions { 16 | return { 17 | autoClose: false, 18 | actions: [ 19 | { 20 | label: trans.__('Show'), 21 | callback: () => { 22 | showErrorMessage( 23 | trans.__('Error'), 24 | { 25 | // Render error in a
 element to preserve line breaks and
26 |               // use a monospace font so e.g. pre-commit errors are readable.
27 |               // Ref: https://github.com/jupyterlab/jupyterlab-git/issues/1407
28 |               message: (
29 |                 
30 |                   {error.message || error.stack || String(error)}
31 |                 
32 | ) 33 | }, 34 | [Dialog.warnButton({ label: trans.__('Dismiss') })] 35 | ); 36 | }, 37 | displayType: 'warn' 38 | } as Notification.IAction 39 | ] 40 | }; 41 | } 42 | 43 | /** 44 | * Display additional information in a dialog from a notification 45 | * button. 46 | * 47 | * Note: it will not add a button if the message is empty. 48 | * 49 | * @param message Details to display 50 | * @param trans Translation object 51 | * @returns Notification option to display the message 52 | */ 53 | export function showDetails( 54 | message: string, 55 | trans: TranslationBundle 56 | ): Notification.IOptions { 57 | return message 58 | ? { 59 | autoClose: 5000, 60 | actions: [ 61 | { 62 | label: trans.__('Details'), 63 | callback: () => { 64 | showErrorMessage(trans.__('Detailed message'), message, [ 65 | Dialog.okButton({ label: trans.__('Dismiss') }) 66 | ]); 67 | }, 68 | displayType: 'warn' 69 | } as Notification.IAction 70 | ] 71 | } 72 | : {}; 73 | } 74 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | import { ServerConnection } from '@jupyterlab/services'; 3 | import { Git } from './tokens'; 4 | import { requestAPI } from './git'; 5 | import { version } from './version'; 6 | import { TranslationBundle } from '@jupyterlab/translation'; 7 | 8 | /** 9 | * Obtain the server settings or provide meaningful error message for the end user 10 | * 11 | * @returns The server settings 12 | * 13 | * @throws {ServerConnection.ResponseError} If the response was not ok 14 | * @throws {ServerConnection.NetworkError} If the request failed to reach the server 15 | */ 16 | export async function getServerSettings( 17 | trans: TranslationBundle 18 | ): Promise { 19 | try { 20 | const endpoint = 'settings' + URLExt.objectToQueryString({ version }); 21 | const settings = await requestAPI(endpoint, 'GET'); 22 | return settings; 23 | } catch (error) { 24 | if (error instanceof Git.GitResponseError) { 25 | const response = error.response; 26 | if (response.status === 404) { 27 | const message = trans.__( 28 | 'Git server extension is unavailable. Please ensure you have installed the ' + 29 | 'JupyterLab Git server extension by running: pip install --upgrade jupyterlab-git. ' + 30 | 'To confirm that the server extension is installed, run: jupyter server extension list.' 31 | ); 32 | throw new ServerConnection.ResponseError(response, message); 33 | } else { 34 | const message = error.message; 35 | console.error('Failed to get the server extension settings', message); 36 | throw new ServerConnection.ResponseError(response, message); 37 | } 38 | } else { 39 | throw error; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/style/ActionButtonStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | import type { NestedCSSProperties } from 'typestyle/lib/types'; 3 | 4 | export const actionButtonStyle = style({ 5 | flex: '0 0 auto', 6 | background: 'none', 7 | lineHeight: '0px', 8 | padding: '0px 0px', 9 | width: '16px', 10 | border: 'none', 11 | outline: 'none', 12 | cursor: 'pointer', 13 | margin: '0 4px', 14 | 15 | $nest: { 16 | '&:active': { 17 | transform: 'scale(1.272019649)', 18 | overflow: 'hidden', 19 | backgroundColor: 'var(--jp-layout-color3)' 20 | }, 21 | 22 | '&:disabled': { 23 | opacity: 0.4, 24 | background: 'none', 25 | cursor: 'not-allowed' 26 | }, 27 | 28 | '&:hover': { 29 | backgroundColor: 'var(--jp-layout-color2)' 30 | } 31 | } 32 | }); 33 | 34 | export const hiddenButtonStyle = style({ 35 | display: 'none' 36 | }); 37 | 38 | export const showButtonOnHover = (() => { 39 | const styled: NestedCSSProperties = { 40 | $nest: {} 41 | }; 42 | const selector = `&:hover .${hiddenButtonStyle}`; 43 | styled.$nest![selector] = { 44 | display: 'block' 45 | }; 46 | return styled; 47 | })(); 48 | -------------------------------------------------------------------------------- /src/style/BranchMenu.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | import { showButtonOnHover } from './ActionButtonStyle'; 3 | 4 | export const nameClass = style({ 5 | flex: '1 1 auto', 6 | textOverflow: 'ellipsis', 7 | overflow: 'hidden', 8 | whiteSpace: 'nowrap' 9 | }); 10 | 11 | export const wrapperClass = style({ 12 | marginTop: '6px', 13 | marginBottom: '0', 14 | 15 | borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)' 16 | }); 17 | 18 | export const filterWrapperClass = style({ 19 | padding: '4px 11px 4px', 20 | display: 'flex' 21 | }); 22 | 23 | export const filterClass = style({ 24 | flex: '1 1 auto', 25 | boxSizing: 'border-box', 26 | display: 'inline-block', 27 | position: 'relative', 28 | fontSize: 'var(--jp-ui-font-size1)' 29 | }); 30 | 31 | export const filterInputClass = style({ 32 | boxSizing: 'border-box', 33 | 34 | width: '100%', 35 | height: '2em', 36 | 37 | /* top | right | bottom | left */ 38 | padding: '1px 18px 2px 7px', 39 | 40 | color: 'var(--jp-ui-font-color1)', 41 | fontSize: 'var(--jp-ui-font-size1)', 42 | fontWeight: 300, 43 | 44 | backgroundColor: 'var(--jp-layout-color1)', 45 | 46 | border: 'var(--jp-border-width) solid var(--jp-border-color2)', 47 | borderRadius: '3px', 48 | 49 | $nest: { 50 | '&:active': { 51 | border: 'var(--jp-border-width) solid var(--jp-brand-color1)' 52 | }, 53 | '&:focus': { 54 | border: 'var(--jp-border-width) solid var(--jp-brand-color1)' 55 | } 56 | } 57 | }); 58 | 59 | export const filterClearClass = style({ 60 | position: 'absolute', 61 | right: '5px', 62 | top: '0.6em', 63 | 64 | height: '1.1em', 65 | width: '1.1em', 66 | 67 | padding: 0, 68 | 69 | backgroundColor: 'var(--jp-inverse-layout-color4)', 70 | 71 | border: 'none', 72 | borderRadius: '50%', 73 | 74 | $nest: { 75 | svg: { 76 | width: '0.5em!important', 77 | height: '0.5em!important', 78 | 79 | fill: 'var(--jp-ui-inverse-font-color0)' 80 | }, 81 | '&:hover': { 82 | backgroundColor: 'var(--jp-inverse-layout-color3)' 83 | }, 84 | '&:active': { 85 | backgroundColor: 'var(--jp-inverse-layout-color2)' 86 | } 87 | } 88 | }); 89 | 90 | export const newBranchButtonClass = style({ 91 | boxSizing: 'border-box', 92 | 93 | width: '7.7em', 94 | height: '2em', 95 | flex: '0 0 auto', 96 | 97 | marginLeft: '5px', 98 | 99 | color: 'white', 100 | fontSize: 'var(--jp-ui-font-size1)', 101 | 102 | backgroundColor: 'var(--md-blue-500)', 103 | border: '0', 104 | borderRadius: '3px', 105 | 106 | $nest: { 107 | '&:hover': { 108 | backgroundColor: 'var(--md-blue-600)' 109 | }, 110 | '&:active': { 111 | backgroundColor: 'var(--md-blue-700)' 112 | } 113 | } 114 | }); 115 | 116 | export const listItemClass = style( 117 | { 118 | padding: '4px 11px!important', 119 | userSelect: 'none' 120 | }, 121 | showButtonOnHover 122 | ); 123 | 124 | export const activeListItemClass = style({ 125 | color: 'white!important', 126 | 127 | backgroundColor: 'var(--jp-brand-color1)!important', 128 | 129 | $nest: { 130 | '& .jp-icon-selectable[fill]': { 131 | fill: 'white' 132 | } 133 | } 134 | }); 135 | 136 | export const listItemIconClass = style({ 137 | width: '16px', 138 | height: '16px', 139 | 140 | marginRight: '4px' 141 | }); 142 | -------------------------------------------------------------------------------- /src/style/CommitComparisonBox.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const commitComparisonBoxStyle = style({ 4 | flex: '0 0 auto', 5 | display: 'flex', 6 | flexDirection: 'column', 7 | 8 | marginBlockStart: 0, 9 | marginBlockEnd: 0, 10 | paddingLeft: 0, 11 | 12 | overflowY: 'auto', 13 | 14 | borderTop: 'var(--jp-border-width) solid var(--jp-border-color2)' 15 | }); 16 | 17 | export const commitComparisonDiffStyle = style({ 18 | paddingLeft: 10 19 | }); 20 | -------------------------------------------------------------------------------- /src/style/FileItemStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | import type { NestedCSSProperties } from 'typestyle/lib/types'; 3 | import { actionButtonStyle, showButtonOnHover } from './ActionButtonStyle'; 4 | 5 | export const fileStyle = style( 6 | { 7 | userSelect: 'none', 8 | display: 'flex', 9 | flexDirection: 'row', 10 | alignItems: 'center', 11 | boxSizing: 'border-box', 12 | color: 'var(--jp-ui-font-color1)', 13 | lineHeight: 'var(--jp-private-running-item-height)', 14 | padding: '0px 4px', 15 | listStyleType: 'none', 16 | 17 | $nest: { 18 | '&:hover': { 19 | backgroundColor: 'var(--jp-layout-color2)' 20 | } 21 | } 22 | }, 23 | showButtonOnHover 24 | ); 25 | 26 | export const selectedFileStyle = style( 27 | (() => { 28 | const styled: NestedCSSProperties = { 29 | color: 'white', 30 | background: 'var(--jp-brand-color1)', 31 | 32 | $nest: { 33 | '&:hover': { 34 | color: 'white', 35 | background: 'var(--jp-brand-color1) !important' 36 | }, 37 | '&:hover .jp-icon-selectable[fill]': { 38 | fill: 'white' 39 | }, 40 | '&:hover .jp-icon-selectable[stroke]': { 41 | stroke: 'white' 42 | }, 43 | '& .jp-icon-selectable[fill]': { 44 | fill: 'white' 45 | }, 46 | '& .jp-icon-selectable-inverse[fill]': { 47 | fill: 'var(--jp-brand-color1)' 48 | } 49 | } 50 | }; 51 | 52 | styled.$nest![`& .${actionButtonStyle}:active`] = { 53 | backgroundColor: 'var(--jp-brand-color1)' 54 | }; 55 | 56 | styled.$nest![`& .${actionButtonStyle}:hover`] = { 57 | backgroundColor: 'var(--jp-brand-color1)' 58 | }; 59 | 60 | return styled; 61 | })() 62 | ); 63 | 64 | export const fileChangedLabelStyle = style({ 65 | fontSize: '10px', 66 | marginLeft: '5px' 67 | }); 68 | 69 | export const selectedFileChangedLabelStyle = style({ 70 | color: 'white !important' 71 | }); 72 | 73 | export const fileChangedLabelBrandStyle = style({ 74 | color: 'var(--jp-brand-color0)' 75 | }); 76 | 77 | export const fileChangedLabelWarnStyle = style({ 78 | color: 'var(--jp-warn-color0)', 79 | fontWeight: 'bold' 80 | }); 81 | 82 | export const fileChangedLabelInfoStyle = style({ 83 | color: 'var(--jp-info-color0)' 84 | }); 85 | 86 | export const fileGitButtonStyle = style({ 87 | display: 'none' 88 | }); 89 | 90 | export const fileButtonStyle = style({ 91 | marginTop: '5px' 92 | }); 93 | 94 | export const gitMarkBoxStyle = style({ 95 | flex: '0 0 auto' 96 | }); 97 | 98 | export const checkboxLabelStyle = style({ 99 | display: 'flex', 100 | alignItems: 'center' 101 | }); 102 | 103 | export const checkboxLabelContainerStyle = style({ 104 | display: 'flex', 105 | width: '100%' 106 | }); 107 | 108 | export const checkboxLabelLastContainerStyle = style({ 109 | display: 'flex', 110 | marginLeft: 'auto', 111 | overflow: 'hidden' 112 | }); 113 | -------------------------------------------------------------------------------- /src/style/FileListStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const fileListWrapperClass = style({ 4 | flex: '1 1 auto', 5 | minHeight: '150px', 6 | 7 | overflow: 'hidden', 8 | overflowY: 'auto' 9 | }); 10 | -------------------------------------------------------------------------------- /src/style/FilePathStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const fileIconStyle = style({ 4 | flex: '0 0 auto', 5 | height: '16px', 6 | width: '16px', 7 | marginRight: '4px' 8 | }); 9 | 10 | export const fileLabelStyle = style({ 11 | flex: '1 1 auto', 12 | fontSize: 'var(--jp-ui-font-size1)', 13 | overflow: 'hidden', 14 | textOverflow: 'ellipsis', 15 | whiteSpace: 'nowrap' 16 | }); 17 | 18 | export const folderLabelStyle = style({ 19 | color: 'var(--jp-ui-font-color2)', 20 | fontSize: 'var(--jp-ui-font-size0)', 21 | margin: '0px 4px' 22 | }); 23 | -------------------------------------------------------------------------------- /src/style/GitPanel.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const panelWrapperClass = style({ 4 | display: 'flex', 5 | flexDirection: 'column', 6 | height: '100%', 7 | overflowY: 'auto' 8 | }); 9 | 10 | export const warningTextClass = style({ 11 | fontSize: 'var(--jp-ui-font-size1)', 12 | lineHeight: 'var(--jp-content-line-height)', 13 | margin: '13px 11px 4px 11px', 14 | textAlign: 'left' 15 | }); 16 | 17 | export const repoButtonClass = style({ 18 | alignSelf: 'center', 19 | boxSizing: 'border-box', 20 | 21 | height: '28px', 22 | width: '200px', 23 | marginTop: '5px', 24 | border: '0', 25 | borderRadius: '3px', 26 | 27 | color: 'white', 28 | fontSize: 'var(--jp-ui-font-size1)', 29 | 30 | backgroundColor: 'var(--md-blue-500)', 31 | $nest: { 32 | '&:hover': { 33 | backgroundColor: 'var(--md-blue-600)' 34 | }, 35 | '&:active': { 36 | backgroundColor: 'var(--md-blue-700)' 37 | } 38 | } 39 | }); 40 | 41 | export const tabsClass = style({ 42 | minHeight: '36px!important', 43 | 44 | $nest: { 45 | 'button:last-of-type': { 46 | borderRight: 'none' 47 | } 48 | } 49 | }); 50 | 51 | export const tabClass = style({ 52 | width: '50%', 53 | minWidth: '0!important', 54 | maxWidth: '50%!important', 55 | minHeight: '36px!important', 56 | 57 | color: 'var(--jp-ui-font-color1)!important', 58 | backgroundColor: 'var(--jp-layout-color2)!important', 59 | 60 | borderBottom: 61 | 'var(--jp-border-width) solid var(--jp-border-color2)!important', 62 | borderRight: 'var(--jp-border-width) solid var(--jp-border-color2)!important', 63 | 64 | // @ts-expect-error unknown value 65 | textTransform: 'none !important' 66 | }); 67 | 68 | export const selectedTabClass = style({ 69 | backgroundColor: 'var(--jp-layout-color1)!important' 70 | }); 71 | 72 | export const tabIndicatorClass = style({ 73 | height: '3px!important', 74 | 75 | backgroundColor: 'var(--jp-brand-color1)!important', 76 | transition: 'none!important' 77 | }); 78 | -------------------------------------------------------------------------------- /src/style/GitStageStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | import type { NestedCSSProperties } from 'typestyle/lib/types'; 3 | import { hiddenButtonStyle, showButtonOnHover } from './ActionButtonStyle'; 4 | 5 | export const sectionAreaStyle = style( 6 | { 7 | display: 'flex', 8 | flexDirection: 'row', 9 | alignItems: 'center', 10 | padding: '4px', 11 | fontWeight: 600, 12 | borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)', 13 | letterSpacing: '1px', 14 | fontSize: '12px', 15 | overflowY: 'hidden', 16 | height: '16px', 17 | 18 | $nest: { 19 | '&:hover': { 20 | backgroundColor: 'var(--jp-layout-color2)' 21 | } 22 | } 23 | }, 24 | showButtonOnHover 25 | ); 26 | 27 | export const sectionFileContainerStyle = style( 28 | (() => { 29 | const styled: NestedCSSProperties = { 30 | margin: '0', 31 | padding: '0', 32 | overflow: 'auto', 33 | $nest: {} 34 | }; 35 | 36 | const focus = `&:focus-within .${sectionAreaStyle} .${hiddenButtonStyle}`; 37 | styled.$nest![focus] = { 38 | display: 'block' 39 | }; 40 | const hoverSelector = `&:hover .${sectionAreaStyle} .${hiddenButtonStyle}`; 41 | styled.$nest![hoverSelector] = { 42 | display: 'block' 43 | }; 44 | return styled; 45 | })() 46 | ); 47 | 48 | export const sectionHeaderLabelStyle = style({ 49 | fontSize: 'var(--jp-ui-font-size1)', 50 | flex: '1 1 auto', 51 | textOverflow: 'ellipsis', 52 | overflow: 'hidden', 53 | whiteSpace: 'nowrap' 54 | }); 55 | 56 | export const sectionHeaderSizeStyle = style({ 57 | fontSize: 'var(--jp-ui-font-size1)', 58 | flex: '0 0 auto', 59 | whiteSpace: 'nowrap', 60 | borderRadius: '2px' 61 | }); 62 | 63 | export const changeStageButtonStyle = style({ 64 | flex: '0 0 auto', 65 | backgroundColor: 'transparent', 66 | height: '13px', 67 | border: 'none', 68 | outline: 'none', 69 | paddingLeft: '0px' 70 | }); 71 | -------------------------------------------------------------------------------- /src/style/GitStashStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | import type { NestedCSSProperties } from 'typestyle/lib/types'; 3 | import { sectionAreaStyle } from './GitStageStyle'; 4 | 5 | export const stashContainerStyle = style( 6 | (() => { 7 | const styled: NestedCSSProperties = { $nest: {} }; 8 | 9 | styled.$nest![`& > .${sectionAreaStyle}`] = { 10 | margin: 0 11 | }; 12 | return styled; 13 | })() 14 | ); 15 | 16 | export const sectionHeaderLabelStyle = style({ 17 | fontSize: 'var(--jp-ui-font-size1)', 18 | flex: '1 1 auto', 19 | textOverflow: 'ellipsis', 20 | overflow: 'hidden', 21 | whiteSpace: 'nowrap', 22 | display: 'flex', 23 | justifyContent: 'space-between', 24 | alignSelf: 'flex-start' 25 | }); 26 | 27 | export const sectionButtonContainerStyle = style({ 28 | display: 'flex' 29 | }); 30 | 31 | export const stashFileStyle = style({ 32 | display: 'flex', 33 | padding: '0 4px' 34 | }); 35 | 36 | export const listStyle = style({ 37 | overflowX: 'hidden', 38 | $nest: { 39 | '&>*': { 40 | margin: 0, 41 | padding: 0 42 | } 43 | } 44 | }); 45 | 46 | export const stashEntryMessageStyle = style({ 47 | textOverflow: 'ellipsis', 48 | overflow: 'hidden', 49 | whiteSpace: 'nowrap', 50 | display: 'inline-block' 51 | }); 52 | -------------------------------------------------------------------------------- /src/style/GitWidgetStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const gitWidgetStyle = style({ 4 | display: 'flex', 5 | flexDirection: 'column', 6 | minWidth: '300px', 7 | color: 'var(--jp-ui-font-color1)', 8 | background: 'var(--jp-layout-color1)', 9 | fontSize: 'var(--jp-ui-font-size1)' 10 | }); 11 | -------------------------------------------------------------------------------- /src/style/HistorySideBarStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const selectedHistoryFileStyle = style({ 4 | minHeight: '48px', 5 | 6 | top: 0, 7 | position: 'sticky', 8 | 9 | flexGrow: 0, 10 | flexShrink: 0, 11 | 12 | overflowX: 'hidden', 13 | 14 | backgroundColor: 'var(--jp-toolbar-active-background)' 15 | }); 16 | 17 | export const noHistoryFoundStyle = style({ 18 | display: 'flex', 19 | justifyContent: 'center', 20 | 21 | padding: '10px 0', 22 | 23 | color: 'var(--jp-ui-font-color2)' 24 | }); 25 | 26 | export const historySideBarStyle = style({ 27 | flex: '1 1 auto', 28 | display: 'flex', 29 | flexDirection: 'column', 30 | 31 | minHeight: '200px', 32 | 33 | marginBlockStart: 0, 34 | marginBlockEnd: 0, 35 | paddingLeft: 0, 36 | paddingRight: '8px', 37 | 38 | overflowY: 'auto' 39 | }); 40 | 41 | export const historySideBarWrapperStyle = style({ 42 | display: 'flex' 43 | }); 44 | -------------------------------------------------------------------------------- /src/style/ManageRemoteDialog.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const remoteDialogClass = style({ 4 | color: 'var(--jp-ui-font-color1)!important', 5 | 6 | borderRadius: '3px!important', 7 | 8 | backgroundColor: 'var(--jp-layout-color1)!important' 9 | }); 10 | 11 | export const remoteDialogInputClass = style({ 12 | display: 'flex', 13 | flexDirection: 'column', 14 | $nest: { 15 | '& > input': { 16 | marginTop: '10px', 17 | lineHeight: '20px' 18 | } 19 | } 20 | }); 21 | 22 | export const actionsWrapperClass = style({ 23 | padding: '15px 0px !important', 24 | justifyContent: 'space-around !important' 25 | }); 26 | 27 | export const existingRemoteWrapperClass = style({ 28 | margin: '1.5rem 0rem 1rem', 29 | padding: '0px' 30 | }); 31 | 32 | export const existingRemoteGridClass = style({ 33 | marginTop: '2px', 34 | display: 'grid', 35 | rowGap: '5px', 36 | columnGap: '10px', 37 | gridTemplateColumns: 'auto auto auto' 38 | }); 39 | -------------------------------------------------------------------------------- /src/style/NewTagDialog.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const historyDialogBoxStyle = style({ 4 | flex: '1 1 auto', 5 | display: 'flex', 6 | flexDirection: 'column', 7 | 8 | minHeight: '200px', 9 | 10 | marginBlockStart: 0, 11 | marginBlockEnd: 0, 12 | paddingLeft: 0, 13 | 14 | listStyleType: 'none' 15 | }); 16 | 17 | export const historyDialogBoxWrapperStyle = style({ 18 | display: 'flex', 19 | height: '200px', 20 | overflowY: 'auto' 21 | }); 22 | 23 | export const activeListItemClass = style({ 24 | backgroundColor: 'var(--jp-brand-color1)!important', 25 | 26 | $nest: { 27 | '& .jp-icon-selectable[fill]': { 28 | fill: 'white' 29 | } 30 | } 31 | }); 32 | 33 | export const commitHeaderBoldClass = style({ 34 | color: 'white!important', 35 | fontWeight: '700' 36 | }); 37 | 38 | export const commitItemBoldClass = style({ 39 | color: 'white!important' 40 | }); 41 | 42 | export const commitWrapperClass = style({ 43 | flexGrow: 0, 44 | display: 'flex', 45 | flexShrink: 0, 46 | flexDirection: 'column', 47 | padding: '5px 0px 5px 10px', 48 | borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)' 49 | }); 50 | 51 | export const commitHeaderClass = style({ 52 | display: 'flex', 53 | color: 'var(--jp-ui-font-color2)', 54 | paddingBottom: '5px' 55 | }); 56 | 57 | export const commitHeaderItemClass = style({ 58 | width: '30%', 59 | 60 | paddingLeft: '0.5em', 61 | 62 | overflow: 'hidden', 63 | whiteSpace: 'nowrap', 64 | textOverflow: 'ellipsis', 65 | textAlign: 'left', 66 | 67 | $nest: { 68 | '&:first-child': { 69 | paddingLeft: 0 70 | } 71 | } 72 | }); 73 | 74 | export const commitBodyClass = style({ 75 | flex: 'auto' 76 | }); 77 | -------------------------------------------------------------------------------- /src/style/PastCommitNode.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const commitWrapperClass = style({ 4 | flexGrow: 0, 5 | display: 'flex', 6 | flexShrink: 0, 7 | flexDirection: 'column', 8 | padding: '5px 0px 5px 10px', 9 | borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)' 10 | }); 11 | 12 | export const commitHeaderClass = style({ 13 | display: 'flex', 14 | color: 'var(--jp-ui-font-color2)', 15 | paddingBottom: '5px' 16 | }); 17 | 18 | export const commitHeaderItemClass = style({ 19 | width: '30%', 20 | 21 | paddingLeft: '0.5em', 22 | 23 | overflow: 'hidden', 24 | whiteSpace: 'nowrap', 25 | textOverflow: 'ellipsis', 26 | textAlign: 'left', 27 | 28 | $nest: { 29 | '&:first-child': { 30 | paddingLeft: 0 31 | } 32 | } 33 | }); 34 | 35 | export const branchWrapperClass = style({ 36 | display: 'flex', 37 | fontSize: '0.8em', 38 | marginLeft: '-5px' 39 | }); 40 | 41 | export const branchClass = style({ 42 | padding: '2px', 43 | // Special case, regardless of theme, because 44 | // backgrounds of colors are not based on theme either 45 | color: 'var(--md-grey-900)', 46 | border: 'var(--jp-border-width) solid var(--md-grey-700)', 47 | borderRadius: '4px', 48 | margin: '3px' 49 | }); 50 | 51 | export const remoteBranchClass = style({ 52 | backgroundColor: 'var(--md-blue-100)' 53 | }); 54 | 55 | export const localBranchClass = style({ 56 | backgroundColor: 'var(--md-orange-100)' 57 | }); 58 | 59 | export const workingBranchClass = style({ 60 | backgroundColor: 'var(--md-red-100)' 61 | }); 62 | 63 | export const commitExpandedClass = style({ 64 | backgroundColor: 'var(--jp-layout-color1)' 65 | }); 66 | 67 | export const commitBodyClass = style({ 68 | flex: 'auto' 69 | }); 70 | 71 | export const iconButtonClass = style({ 72 | // width: '16px', 73 | // height: '16px' 74 | }); 75 | 76 | export const singleFileCommitClass = style({ 77 | $nest: { 78 | '&:hover': { 79 | backgroundColor: 'var(--jp-layout-color2)' 80 | } 81 | } 82 | }); 83 | 84 | export const referenceCommitNodeClass = style({ 85 | borderLeft: '6px solid var(--jp-git-diff-deleted-color1)' 86 | }); 87 | 88 | export const challengerCommitNodeClass = style({ 89 | borderLeft: '6px solid var(--jp-git-diff-added-color1)' 90 | }); 91 | -------------------------------------------------------------------------------- /src/style/RebaseActionStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const rebaseActionStyle = style({ 4 | padding: '8px' 5 | }); 6 | -------------------------------------------------------------------------------- /src/style/SinglePastCommitInfo.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const commitClass = style({ 4 | flex: '0 0 auto', 5 | width: '100%', 6 | fontSize: '12px', 7 | marginBottom: '10px', 8 | marginTop: '5px', 9 | paddingTop: '5px' 10 | }); 11 | 12 | export const commitOverviewNumbersClass = style({ 13 | fontSize: '13px', 14 | fontWeight: 'bold', 15 | paddingTop: '5px', 16 | $nest: { 17 | '& span': { 18 | alignItems: 'center', 19 | display: 'inline-flex', 20 | marginLeft: '5px' 21 | }, 22 | '& span:nth-of-type(1)': { 23 | marginLeft: '0px' 24 | } 25 | } 26 | }); 27 | 28 | export const commitDetailClass = style({ 29 | flex: '1 1 auto', 30 | margin: '0' 31 | }); 32 | 33 | export const commitDetailHeaderClass = style({ 34 | paddingBottom: '0.5em', 35 | fontSize: '13px', 36 | fontWeight: 'bold' 37 | }); 38 | 39 | export const commitDetailFileClass = style({ 40 | userSelect: 'none', 41 | display: 'flex', 42 | flexDirection: 'row', 43 | alignItems: 'center', 44 | color: 'var(--jp-ui-font-color1)', 45 | height: 'var(--jp-private-running-item-height)', 46 | lineHeight: 'var(--jp-private-running-item-height)', 47 | whiteSpace: 'nowrap', 48 | 49 | overflow: 'hidden', 50 | 51 | $nest: { 52 | '&:hover': { 53 | backgroundColor: 'var(--jp-layout-color2)' 54 | }, 55 | '&:active': { 56 | backgroundColor: 'var(--jp-layout-color3)' 57 | } 58 | } 59 | }); 60 | 61 | export const iconClass = style({ 62 | display: 'inline-block', 63 | width: '13px', 64 | height: '13px', 65 | right: '10px' 66 | }); 67 | 68 | export const insertionsIconClass = style({ 69 | $nest: { 70 | '.jp-icon3': { 71 | fill: 'var(--md-green-500)' 72 | } 73 | } 74 | }); 75 | 76 | export const deletionsIconClass = style({ 77 | $nest: { 78 | '.jp-icon3': { 79 | fill: 'var(--md-red-500)' 80 | } 81 | } 82 | }); 83 | 84 | export const fileListClass = style({ 85 | $nest: { 86 | ul: { 87 | paddingLeft: 0, 88 | margin: 0 89 | } 90 | } 91 | }); 92 | 93 | export const actionButtonClass = style({ 94 | float: 'right' 95 | }); 96 | 97 | export const commitBodyClass = style({ 98 | paddingTop: '5px', 99 | whiteSpace: 'pre-wrap', 100 | wordWrap: 'break-word', 101 | margin: '0' 102 | }); 103 | -------------------------------------------------------------------------------- /src/style/StatusWidget.ts: -------------------------------------------------------------------------------- 1 | import { keyframes, style } from 'typestyle'; 2 | 3 | const fillAnimation = keyframes({ 4 | to: { fillOpacity: 1 } 5 | }); 6 | 7 | export const statusIconClass = style({ 8 | $nest: { 9 | '& .jp-icon3': { 10 | animationName: fillAnimation, 11 | animationDuration: '1s' 12 | } 13 | } 14 | }); 15 | 16 | const pathAnimation = keyframes({ 17 | '0%': { fillOpacity: 1 }, 18 | '50%': { fillOpacity: 0.6 }, 19 | '100%': { fillOpacity: 1 } 20 | }); 21 | 22 | export const statusAnimatedIconClass = style({ 23 | $nest: { 24 | '& .jp-icon3': { 25 | animationName: pathAnimation, 26 | animationDuration: '2s', 27 | animationIterationCount: 'infinite' 28 | } 29 | } 30 | }); 31 | 32 | export const badgeClass = style({ 33 | $nest: { 34 | '& > .MuiBadge-badge': { 35 | top: 6, 36 | right: 15, 37 | backgroundColor: 'var(--jp-warn-color1)' 38 | } 39 | } 40 | }); 41 | 42 | export const currentBranchNameClass = style({ 43 | fontSize: 'var(--jp-ui-font-size1)', 44 | lineHeight: '100%' 45 | }); 46 | -------------------------------------------------------------------------------- /src/style/SubmoduleMenuStyle.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const submoduleHeaderStyle = style({ 4 | padding: '4px 4px 1px', 5 | margin: '0 6px', 6 | fontWeight: 600, 7 | letterSpacing: '1px', 8 | fontSize: '12px', 9 | overflowY: 'hidden', 10 | borderBottom: '3px solid var(--jp-brand-color1)', 11 | height: '16px' 12 | }); 13 | -------------------------------------------------------------------------------- /src/style/SuspendModal.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const fullscreenProgressClass = style({ 4 | position: 'absolute', 5 | top: '50%', 6 | left: '50%', 7 | color: 'var(--jp-ui-inverse-font-color0)', 8 | textAlign: 'center' 9 | }); 10 | -------------------------------------------------------------------------------- /src/style/Toolbar.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const toolbarClass = style({ 4 | display: 'flex', 5 | flexDirection: 'column', 6 | 7 | backgroundColor: 'var(--jp-layout-color1)' 8 | }); 9 | 10 | export const toolbarNavClass = style({ 11 | display: 'flex', 12 | flexDirection: 'row', 13 | flexWrap: 'wrap', 14 | 15 | minHeight: '35px', 16 | lineHeight: 'var(--jp-private-running-item-height)', 17 | 18 | backgroundColor: 'var(--jp-layout-color1)', 19 | 20 | borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)' 21 | }); 22 | 23 | export const toolbarMenuWrapperClass = style({ 24 | background: 'var(--jp-layout-color1)' 25 | }); 26 | 27 | export const toolbarMenuButtonClass = style({ 28 | boxSizing: 'border-box', 29 | display: 'flex', 30 | flexDirection: 'row', 31 | flexWrap: 'wrap', 32 | 33 | width: '100%', 34 | minHeight: '50px', 35 | 36 | /* top | right | bottom | left */ 37 | padding: '4px 11px 4px 11px', 38 | 39 | fontSize: 'var(--jp-ui-font-size1)', 40 | lineHeight: '1.5em', 41 | color: 'var(--jp-ui-font-color1)', 42 | textAlign: 'left', 43 | 44 | border: 'none', 45 | borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)', 46 | borderRadius: 0, 47 | 48 | background: 'var(--jp-layout-color1)' 49 | }); 50 | 51 | export const toolbarMenuButtonEnabledClass = style({ 52 | $nest: { 53 | '&:hover': { 54 | backgroundColor: 'var(--jp-layout-color2)' 55 | }, 56 | '&:active': { 57 | backgroundColor: 'var(--jp-layout-color3)' 58 | } 59 | } 60 | }); 61 | 62 | export const toolbarMenuButtonIconClass = style({ 63 | width: '16px', 64 | height: '16px', 65 | 66 | /* top | right | bottom | left */ 67 | margin: 'auto 8px auto 0' 68 | }); 69 | 70 | export const toolbarMenuButtonTitleWrapperClass = style({ 71 | flexBasis: 0, 72 | flexGrow: 1, 73 | 74 | marginTop: 'auto', 75 | marginBottom: 'auto', 76 | marginRight: 'auto', 77 | 78 | $nest: { 79 | '& > p': { 80 | marginTop: 0, 81 | marginBottom: 0 82 | } 83 | } 84 | }); 85 | 86 | export const toolbarMenuButtonTitleClass = style({}); 87 | 88 | export const toolbarMenuButtonSubtitleClass = style({ 89 | marginBottom: 'auto', 90 | 91 | fontWeight: 700 92 | }); 93 | 94 | // Styles overriding default button style are marked as important to ensure application 95 | export const toolbarButtonClass = style({ 96 | boxSizing: 'border-box', 97 | height: '24px', 98 | width: 'var(--jp-private-running-button-width) !important', 99 | 100 | margin: 'auto 0 auto 0', 101 | padding: '0px 6px !important', 102 | 103 | $nest: { 104 | '& span': { 105 | // Set icon width and centers it 106 | margin: 'auto', 107 | width: '16px' 108 | } 109 | } 110 | }); 111 | 112 | export const spacer = style({ 113 | flex: '1 1 auto' 114 | }); 115 | 116 | export const badgeClass = style({ 117 | $nest: { 118 | '& > .MuiBadge-badge': { 119 | top: 12, 120 | right: 5, 121 | backgroundColor: 'var(--jp-warn-color1)' 122 | } 123 | } 124 | }); 125 | -------------------------------------------------------------------------------- /src/style/common.ts: -------------------------------------------------------------------------------- 1 | import { style } from 'typestyle'; 2 | 3 | export const toolbarButtonStyle = style({ 4 | width: 'var(--jp-private-running-button-width)', 5 | background: 'var(--jp-layout-color1)', 6 | border: 'none', 7 | backgroundSize: '16px', 8 | backgroundRepeat: 'no-repeat', 9 | backgroundPosition: 'center', 10 | boxSizing: 'border-box', 11 | outline: 'none', 12 | padding: '0px 6px', 13 | margin: 'auto 5px auto 5px', 14 | height: '24px', 15 | 16 | $nest: { 17 | '&:hover': { 18 | backgroundColor: 'var(--jp-layout-color2)' 19 | }, 20 | '&:active': { 21 | backgroundColor: 'var(--jp-layout-color3)' 22 | } 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /src/style/icons.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | 3 | // icon svg import statements 4 | import addSvg from '../../style/icons/add.svg'; 5 | import branchSvg from '../../style/icons/branch.svg'; 6 | import clockSvg from '../../style/icons/clock.svg'; 7 | import cloneSvg from '../../style/icons/clone.svg'; 8 | import compareWithSelectedSvg from '../../style/icons/compare-with-selected.svg'; 9 | import deletionsMadeSvg from '../../style/icons/deletions.svg'; 10 | import desktopSvg from '../../style/icons/desktop.svg'; 11 | import diffSvg from '../../style/icons/diff.svg'; 12 | import discardSvg from '../../style/icons/discard.svg'; 13 | import gitSvg from '../../style/icons/git.svg'; 14 | import insertionsMadeSvg from '../../style/icons/insertions.svg'; 15 | import mergeSvg from '../../style/icons/merge.svg'; 16 | import openSvg from '../../style/icons/open-file.svg'; 17 | import pullSvg from '../../style/icons/pull.svg'; 18 | import pushSvg from '../../style/icons/push.svg'; 19 | import removeSvg from '../../style/icons/remove.svg'; 20 | import rewindSvg from '../../style/icons/rewind.svg'; 21 | import selectForCompareSvg from '../../style/icons/select-for-compare.svg'; 22 | import tagSvg from '../../style/icons/tag.svg'; 23 | import trashSvg from '../../style/icons/trash.svg'; 24 | import verticalMoreSvg from '../../style/icons/vertical-more.svg'; 25 | 26 | export const gitIcon = new LabIcon({ name: 'git', svgstr: gitSvg }); 27 | export const addIcon = new LabIcon({ 28 | name: 'git:add', 29 | svgstr: addSvg 30 | }); 31 | export const branchIcon = new LabIcon({ 32 | name: 'git:branch', 33 | svgstr: branchSvg 34 | }); 35 | export const cloneIcon = new LabIcon({ 36 | name: 'git:clone', 37 | svgstr: cloneSvg 38 | }); 39 | export const compareWithSelectedIcon = new LabIcon({ 40 | name: 'git:compare-with-selected', 41 | svgstr: compareWithSelectedSvg 42 | }); 43 | export const deletionsMadeIcon = new LabIcon({ 44 | name: 'git:deletions', 45 | svgstr: deletionsMadeSvg 46 | }); 47 | export const desktopIcon = new LabIcon({ 48 | name: 'git:desktop', 49 | svgstr: desktopSvg 50 | }); 51 | export const diffIcon = new LabIcon({ 52 | name: 'git:diff', 53 | svgstr: diffSvg 54 | }); 55 | export const discardIcon = new LabIcon({ 56 | name: 'git:discard', 57 | svgstr: discardSvg 58 | }); 59 | export const insertionsMadeIcon = new LabIcon({ 60 | name: 'git:insertions', 61 | svgstr: insertionsMadeSvg 62 | }); 63 | export const historyIcon = new LabIcon({ 64 | name: 'git:history', 65 | svgstr: clockSvg 66 | }); 67 | export const mergeIcon = new LabIcon({ 68 | name: 'git:merge', 69 | svgstr: mergeSvg 70 | }); 71 | export const openIcon = new LabIcon({ 72 | name: 'git:open-file', 73 | svgstr: openSvg 74 | }); 75 | export const pullIcon = new LabIcon({ 76 | name: 'git:pull', 77 | svgstr: pullSvg 78 | }); 79 | export const pushIcon = new LabIcon({ 80 | name: 'git:push', 81 | svgstr: pushSvg 82 | }); 83 | export const removeIcon = new LabIcon({ 84 | name: 'git:remove', 85 | svgstr: removeSvg 86 | }); 87 | export const rewindIcon = new LabIcon({ 88 | name: 'git:rewind', 89 | svgstr: rewindSvg 90 | }); 91 | export const selectForCompareIcon = new LabIcon({ 92 | name: 'git:select-for-compare', 93 | svgstr: selectForCompareSvg 94 | }); 95 | export const tagIcon = new LabIcon({ 96 | name: 'git:tag', 97 | svgstr: tagSvg 98 | }); 99 | export const trashIcon = new LabIcon({ 100 | name: 'git:trash', 101 | svgstr: trashSvg 102 | }); 103 | export const verticalMoreIcon = new LabIcon({ 104 | name: 'git:vertical-more', 105 | svgstr: verticalMoreSvg 106 | }); 107 | -------------------------------------------------------------------------------- /src/svg.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | // including this file in a package allows for the use of import statements 5 | // with svg files. Example: `import xSvg from 'path/xSvg.svg'` 6 | 7 | // for use with raw-loader in Webpack. 8 | // The svg will be imported as a raw string 9 | 10 | declare module '*.svg' { 11 | const value: string; 12 | export default value; 13 | } 14 | -------------------------------------------------------------------------------- /src/svgPathData.ts: -------------------------------------------------------------------------------- 1 | // a canvas like api for building an svg path data attribute 2 | export class SVGPathData { 3 | constructor() { 4 | this._SVGPath = []; 5 | } 6 | toString(): string { 7 | return this._SVGPath.join(' '); 8 | } 9 | moveTo(x: number, y: number): void { 10 | this._SVGPath.push(`M ${x},${y}`); 11 | } 12 | lineTo(x: number, y: number): void { 13 | this._SVGPath.push(`L ${x},${y}`); 14 | } 15 | closePath(): void { 16 | this._SVGPath.push('Z'); 17 | } 18 | bezierCurveTo( 19 | cp1x: number, 20 | cp1y: number, 21 | cp2x: number, 22 | cp2y: number, 23 | x: number, 24 | y: number 25 | ): void { 26 | this._SVGPath.push(`C ${cp1x}, ${cp1y}, ${cp2x}, ${cp2y}, ${x}, ${y}`); 27 | } 28 | 29 | private _SVGPath: string[]; 30 | } 31 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | // generated by genversion 2 | export const version = '0.51.2'; 3 | -------------------------------------------------------------------------------- /src/widgets/AuthorBox.ts: -------------------------------------------------------------------------------- 1 | import { Dialog } from '@jupyterlab/apputils'; 2 | import { nullTranslator, TranslationBundle } from '@jupyterlab/translation'; 3 | import { Widget } from '@lumino/widgets'; 4 | import { Git } from '../tokens'; 5 | 6 | /** 7 | * The UI for the commit author form 8 | */ 9 | export class GitAuthorForm 10 | extends Widget 11 | implements Dialog.IBodyWidget 12 | { 13 | constructor({ 14 | author, 15 | trans 16 | }: { 17 | author: Git.IIdentity; 18 | trans: TranslationBundle; 19 | }) { 20 | super(); 21 | this._populateForm(author, trans); 22 | } 23 | 24 | private _populateForm( 25 | author: Git.IIdentity, 26 | trans?: TranslationBundle 27 | ): void { 28 | trans ??= nullTranslator.load('jupyterlab_git'); 29 | const nameLabel = document.createElement('label'); 30 | nameLabel.textContent = trans.__('Committer name:'); 31 | const emailLabel = document.createElement('label'); 32 | emailLabel.textContent = trans.__('Committer email:'); 33 | 34 | this._name = nameLabel.appendChild(document.createElement('input')); 35 | this._email = emailLabel.appendChild(document.createElement('input')); 36 | this._name.placeholder = 'Name'; 37 | this._email.type = 'text'; 38 | this._email.placeholder = 'Email'; 39 | this._email.type = 'email'; 40 | this._name.value = author.name; 41 | this._email.value = author.email; 42 | 43 | this.node.appendChild(nameLabel); 44 | this.node.appendChild(emailLabel); 45 | } 46 | 47 | /** 48 | * Returns the input value. 49 | */ 50 | getValue(): Git.IIdentity { 51 | const credentials = { 52 | name: this._name.value, 53 | email: this._email.value 54 | }; 55 | return credentials; 56 | } 57 | 58 | // @ts-expect-error initialization is indirect 59 | private _name: HTMLInputElement; 60 | // @ts-expect-error initialization is indirect 61 | private _email: HTMLInputElement; 62 | } 63 | -------------------------------------------------------------------------------- /src/widgets/CredentialsBox.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from '@jupyterlab/apputils'; 2 | import { TranslationBundle } from '@jupyterlab/translation'; 3 | import { Widget } from '@lumino/widgets'; 4 | import { Git } from '../tokens'; 5 | 6 | /** 7 | * The UI for the credentials form 8 | */ 9 | export class GitCredentialsForm 10 | extends Widget 11 | implements Dialog.IBodyWidget 12 | { 13 | private _passwordPlaceholder: string; 14 | constructor( 15 | trans: TranslationBundle, 16 | textContent = trans.__('Enter credentials for remote repository'), 17 | warningContent = '', 18 | passwordPlaceholder = trans.__('password / personal access token') 19 | ) { 20 | super(); 21 | this._trans = trans; 22 | this._passwordPlaceholder = passwordPlaceholder; 23 | this.node.appendChild(this.createBody(textContent, warningContent)); 24 | } 25 | 26 | private createBody(textContent: string, warningContent: string): HTMLElement { 27 | const node = document.createElement('div'); 28 | const label = document.createElement('label'); 29 | 30 | const checkboxLabel = document.createElement('label'); 31 | this._checkboxCacheCredentials = document.createElement('input'); 32 | const checkboxText = document.createElement('span'); 33 | 34 | this._user = document.createElement('input'); 35 | this._user.type = 'text'; 36 | this._password = document.createElement('input'); 37 | this._password.type = 'password'; 38 | 39 | const text = document.createElement('span'); 40 | const warning = document.createElement('div'); 41 | 42 | node.className = 'jp-CredentialsBox'; 43 | warning.className = 'jp-CredentialsBox-warning'; 44 | text.textContent = textContent; 45 | warning.textContent = warningContent; 46 | this._user.placeholder = this._trans.__('username'); 47 | this._password.placeholder = this._passwordPlaceholder; 48 | 49 | checkboxLabel.className = 'jp-CredentialsBox-label-checkbox'; 50 | this._checkboxCacheCredentials.type = 'checkbox'; 51 | checkboxText.textContent = this._trans.__('Save my login temporarily'); 52 | 53 | label.appendChild(text); 54 | label.appendChild(this._user); 55 | label.appendChild(this._password); 56 | node.appendChild(label); 57 | node.appendChild(warning); 58 | 59 | checkboxLabel.appendChild(this._checkboxCacheCredentials); 60 | checkboxLabel.appendChild(checkboxText); 61 | node.appendChild(checkboxLabel); 62 | 63 | return node; 64 | } 65 | 66 | /** 67 | * Returns the input value. 68 | */ 69 | getValue(): Git.IAuth { 70 | return { 71 | username: this._user.value, 72 | password: this._password.value, 73 | cache_credentials: this._checkboxCacheCredentials.checked 74 | }; 75 | } 76 | protected _trans: TranslationBundle; 77 | // @ts-expect-error initialization is indirect 78 | private _user: HTMLInputElement; 79 | // @ts-expect-error initialization is indirect 80 | private _password: HTMLInputElement; 81 | // @ts-expect-error initialization is indirect 82 | private _checkboxCacheCredentials: HTMLInputElement; 83 | } 84 | -------------------------------------------------------------------------------- /src/widgets/GitCloneForm.ts: -------------------------------------------------------------------------------- 1 | import { TranslationBundle } from '@jupyterlab/translation'; 2 | import { Widget } from '@lumino/widgets'; 3 | 4 | /** 5 | * The UI for the form fields shown within the Clone modal. 6 | */ 7 | export class GitCloneForm extends Widget { 8 | /** 9 | * Create a redirect form. 10 | * @param translator - The language translator 11 | */ 12 | constructor(trans: TranslationBundle) { 13 | super({ node: GitCloneForm.createFormNode(trans) }); 14 | } 15 | 16 | /** 17 | * Returns the input value. 18 | */ 19 | getValue(): { url: string; versioning: boolean; submodules: boolean } { 20 | return { 21 | url: encodeURIComponent( 22 | ( 23 | this.node.querySelector('#input-link') as HTMLInputElement 24 | ).value.trim() 25 | ), 26 | versioning: Boolean( 27 | encodeURIComponent( 28 | (this.node.querySelector('#download') as HTMLInputElement).checked 29 | ) 30 | ), 31 | submodules: Boolean( 32 | encodeURIComponent( 33 | (this.node.querySelector('#submodules') as HTMLInputElement).checked 34 | ) 35 | ) 36 | }; 37 | } 38 | 39 | private static createFormNode(trans: TranslationBundle): HTMLElement { 40 | const node = document.createElement('div'); 41 | const inputWrapper = document.createElement('div'); 42 | const inputLinkLabel = document.createElement('label'); 43 | const inputLink = document.createElement('input'); 44 | const linkText = document.createElement('span'); 45 | const checkboxWrapper = document.createElement('div'); 46 | const submodulesLabel = document.createElement('label'); 47 | const submodules = document.createElement('input'); 48 | const downloadLabel = document.createElement('label'); 49 | const download = document.createElement('input'); 50 | 51 | node.className = 'jp-CredentialsBox'; 52 | inputWrapper.className = 'jp-RedirectForm'; 53 | checkboxWrapper.className = 'jp-CredentialsBox-wrapper'; 54 | submodulesLabel.className = 'jp-CredentialsBox-label-checkbox'; 55 | downloadLabel.className = 'jp-CredentialsBox-label-checkbox'; 56 | submodules.id = 'submodules'; 57 | download.id = 'download'; 58 | inputLink.id = 'input-link'; 59 | 60 | linkText.textContent = trans.__( 61 | 'Enter the URI of the remote Git repository' 62 | ); 63 | inputLink.placeholder = 'https://host.com/org/repo.git'; 64 | 65 | submodulesLabel.textContent = trans.__('Include submodules'); 66 | submodulesLabel.title = trans.__( 67 | 'If checked, the remote submodules in the repository will be cloned recursively' 68 | ); 69 | submodules.setAttribute('type', 'checkbox'); 70 | submodules.setAttribute('checked', 'checked'); 71 | 72 | downloadLabel.textContent = trans.__('Download the repository'); 73 | downloadLabel.title = trans.__( 74 | 'If checked, the remote repository default branch will be downloaded instead of cloned' 75 | ); 76 | download.setAttribute('type', 'checkbox'); 77 | 78 | inputLinkLabel.appendChild(linkText); 79 | inputLinkLabel.appendChild(inputLink); 80 | 81 | inputWrapper.append(inputLinkLabel); 82 | 83 | submodulesLabel.prepend(submodules); 84 | checkboxWrapper.appendChild(submodulesLabel); 85 | 86 | downloadLabel.prepend(download); 87 | checkboxWrapper.appendChild(downloadLabel); 88 | 89 | node.appendChild(inputWrapper); 90 | node.appendChild(checkboxWrapper); 91 | 92 | return node; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/widgets/GitResetToRemoteForm.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from '@jupyterlab/apputils'; 2 | import { Widget } from '@lumino/widgets'; 3 | import { Git } from '../tokens'; 4 | 5 | /** 6 | * A widget form containing a text block and a checkbox, 7 | * can be used as a Dialog body. 8 | */ 9 | export class CheckboxForm 10 | extends Widget 11 | implements Dialog.IBodyWidget 12 | { 13 | constructor(textBody: string, checkboxLabel: string) { 14 | super(); 15 | this.node.appendChild(this.createBody(textBody, checkboxLabel)); 16 | } 17 | 18 | private createBody(textBody: string, checkboxLabel: string): HTMLElement { 19 | const mainNode = document.createElement('div'); 20 | 21 | const text = document.createElement('div'); 22 | text.textContent = textBody; 23 | 24 | const checkboxContainer = document.createElement('label'); 25 | 26 | this._checkbox = document.createElement('input'); 27 | this._checkbox.type = 'checkbox'; 28 | this._checkbox.checked = true; 29 | 30 | const label = document.createElement('span'); 31 | label.textContent = checkboxLabel; 32 | 33 | checkboxContainer.appendChild(this._checkbox); 34 | checkboxContainer.appendChild(label); 35 | 36 | mainNode.appendChild(text); 37 | mainNode.appendChild(checkboxContainer); 38 | 39 | return mainNode; 40 | } 41 | 42 | getValue(): Git.ICheckboxFormValue { 43 | return { 44 | checked: this._checkbox.checked 45 | }; 46 | } 47 | 48 | // @ts-expect-error initialization is indirect 49 | private _checkbox: HTMLInputElement; 50 | } 51 | -------------------------------------------------------------------------------- /src/widgets/GitWidget.tsx: -------------------------------------------------------------------------------- 1 | import { ReactWidget } from '@jupyterlab/apputils'; 2 | import { FileBrowserModel } from '@jupyterlab/filebrowser'; 3 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 4 | import { TranslationBundle } from '@jupyterlab/translation'; 5 | import { CommandRegistry } from '@lumino/commands'; 6 | import { Message } from '@lumino/messaging'; 7 | import { Widget } from '@lumino/widgets'; 8 | import * as React from 'react'; 9 | import { GitPanel } from '../components/GitPanel'; 10 | import { GitExtension } from '../model'; 11 | import { gitWidgetStyle } from '../style/GitWidgetStyle'; 12 | 13 | /** 14 | * A class that exposes the git plugin Widget. 15 | */ 16 | export class GitWidget extends ReactWidget { 17 | constructor( 18 | model: GitExtension, 19 | settings: ISettingRegistry.ISettings, 20 | commands: CommandRegistry, 21 | fileBrowserModel: FileBrowserModel, 22 | trans: TranslationBundle, 23 | options?: Widget.IOptions 24 | ) { 25 | super(); 26 | this.node.id = 'GitSession-root'; 27 | this.addClass(gitWidgetStyle); 28 | 29 | this._trans = trans; 30 | this._commands = commands; 31 | this._fileBrowserModel = fileBrowserModel; 32 | this._model = model; 33 | this._settings = settings; 34 | 35 | // Add refresh standby condition if this widget is hidden 36 | model.refreshStandbyCondition = (): boolean => 37 | !this._settings.composite['refreshIfHidden'] && this.isHidden; 38 | } 39 | 40 | /** 41 | * A message handler invoked on a `'before-show'` message. 42 | * 43 | * #### Notes 44 | * The default implementation of this handler is a no-op. 45 | */ 46 | onBeforeShow(msg: Message): void { 47 | // Trigger refresh when the widget is displayed 48 | this._model.refresh().catch(error => { 49 | console.error('Fail to refresh model when displaying GitWidget.', error); 50 | }); 51 | super.onBeforeShow(msg); 52 | } 53 | 54 | /** 55 | * Render the content of this widget using the virtual DOM. 56 | * 57 | * This method will be called anytime the widget needs to be rendered, which 58 | * includes layout triggered rendering. 59 | */ 60 | render(): JSX.Element { 61 | return ( 62 | 69 | ); 70 | } 71 | 72 | private _commands: CommandRegistry; 73 | private _fileBrowserModel: FileBrowserModel; 74 | private _model: GitExtension; 75 | private _settings: ISettingRegistry.ISettings; 76 | private _trans: TranslationBundle; 77 | } 78 | -------------------------------------------------------------------------------- /src/widgets/discardAllChanges.ts: -------------------------------------------------------------------------------- 1 | import { showDialog, Dialog, showErrorMessage } from '@jupyterlab/apputils'; 2 | import { TranslationBundle } from '@jupyterlab/translation'; 3 | import { IGitExtension } from '../tokens'; 4 | 5 | /** 6 | * Discard changes in all unstaged and staged files 7 | * 8 | * @param isFallback If dialog is called when the classical pull operation fails 9 | */ 10 | export async function discardAllChanges( 11 | model: IGitExtension, 12 | trans: TranslationBundle, 13 | isFallback?: boolean 14 | ): Promise { 15 | const result = await showDialog({ 16 | title: trans.__('Discard all changes'), 17 | body: isFallback 18 | ? trans.__( 19 | 'Your current changes forbid pulling the latest changes. Do you want to permanently discard those changes? This action cannot be undone.' 20 | ) 21 | : trans.__( 22 | 'Are you sure you want to permanently discard changes to all files? This action cannot be undone.' 23 | ), 24 | buttons: [ 25 | Dialog.cancelButton({ label: trans.__('Cancel') }), 26 | Dialog.warnButton({ label: trans.__('Discard') }) 27 | ] 28 | }); 29 | 30 | if (result.button.accept) { 31 | try { 32 | return model.resetToCommit('HEAD'); 33 | } catch (reason: any) { 34 | showErrorMessage(trans.__('Discard all changes failed.'), reason); 35 | return Promise.reject(reason); 36 | } 37 | } 38 | 39 | return Promise.reject({ 40 | cancelled: true, 41 | message: 'The user refused to discard all changes' 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /style/advanced-push-form.css: -------------------------------------------------------------------------------- 1 | .jp-remote-text { 2 | font-size: 1rem; 3 | } 4 | 5 | .jp-remote-options-wrapper { 6 | margin: 4px; 7 | display: flex; 8 | flex-direction: column; 9 | align-items: stretch; 10 | row-gap: 5px; 11 | } 12 | 13 | .jp-button-wrapper { 14 | display: flex; 15 | gap: 0.5rem; 16 | align-items: center; 17 | } 18 | 19 | .jp-option { 20 | height: fit-content !important; 21 | appearance: auto !important; 22 | margin: 0; 23 | } 24 | 25 | .jp-force-box-container { 26 | margin-top: 1rem; 27 | display: flex; 28 | align-items: flex-end; 29 | column-gap: 5px; 30 | } 31 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |---------------------------------------------------------------------------- */ 5 | 6 | @import url('variables.css'); 7 | @import url('credentials-box.css'); 8 | @import url('diff-common.css'); 9 | @import url('status-widget.css'); 10 | @import url('advanced-push-form.css'); 11 | 12 | .jp-git-tab-mod-preview { 13 | font-style: italic; 14 | } 15 | 16 | .not-saved > .lm-TabBar-tabCloseIcon > :not(:hover) > .jp-icon-busy[fill] { 17 | fill: var(--jp-inverse-layout-color3); 18 | } 19 | 20 | .not-saved > .lm-TabBar-tabCloseIcon > :not(:hover) > .jp-icon3[fill] { 21 | fill: none; 22 | } 23 | -------------------------------------------------------------------------------- /style/credentials-box.css: -------------------------------------------------------------------------------- 1 | .jp-CredentialsBox input[type='text'], 2 | .jp-CredentialsBox input[type='password'] { 3 | display: block; 4 | width: 100%; 5 | margin-top: 10px; 6 | margin-bottom: 10px; 7 | } 8 | 9 | .jp-CredentialsBox input[type='checkbox'] { 10 | display: inline-block; 11 | } 12 | 13 | .jp-CredentialsBox-warning { 14 | color: var(--jp-warn-color0); 15 | } 16 | 17 | .jp-CredentialsBox-label-checkbox { 18 | display: flex; 19 | align-items: center; 20 | } 21 | 22 | .jp-CredentialsBox-wrapper { 23 | margin-top: 10px; 24 | } 25 | -------------------------------------------------------------------------------- /style/icons/add.svg: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /style/icons/branch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /style/icons/clock.svg: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /style/icons/clone.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /style/icons/compare-with-selected.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /style/icons/deletions.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /style/icons/desktop.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /style/icons/diff.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /style/icons/discard.svg: -------------------------------------------------------------------------------- 1 | 6 | 16 | 17 | -------------------------------------------------------------------------------- /style/icons/git.svg: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /style/icons/insertions.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /style/icons/merge.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /style/icons/open-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /style/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /style/icons/pull.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /style/icons/push.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /style/icons/remove.svg: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /style/icons/rewind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /style/icons/select-for-compare.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /style/icons/tag.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /style/icons/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /style/icons/vertical-more.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | /* Import same style from nbdime and nbdime-jupyterlab (see index.ts) */ 2 | @import url('~nbdime/lib/common/collapsible.css'); 3 | @import url('~nbdime/lib/upstreaming/flexpanel.css'); 4 | @import url('~nbdime/lib/common/dragpanel.css'); 5 | @import url('~nbdime/lib/styles/variables.css'); 6 | @import url('~nbdime/lib/styles/common.css'); 7 | @import url('~nbdime/lib/styles/diff.css'); 8 | @import url('~nbdime/lib/styles/merge.css'); 9 | @import url('~nbdime-jupyterlab/style/index.css'); 10 | @import url('base.css'); 11 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /style/status-widget.css: -------------------------------------------------------------------------------- 1 | .jp-git-StatusWidget { 2 | display: flex; 3 | align-items: center; 4 | } 5 | -------------------------------------------------------------------------------- /style/variables.css: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |---------------------------------------------------------------------------- */ 5 | 6 | :root { 7 | --jp-git-diff-added-color: rgb(155 185 85 / 20%); 8 | --jp-git-diff-added-color1: rgb(155 185 85 / 40%); 9 | --jp-git-diff-deleted-color: rgb(255 0 0 / 20%); 10 | --jp-git-diff-deleted-color1: rgb(255 0 0 / 40%); 11 | --jp-git-diff-output-border-color: rgb(0 141 255 / 70%); 12 | --jp-git-diff-output-color: rgb(0 141 255 / 30%); 13 | --jp-merge-local-color: rgb(31 31 224 / 20%); 14 | --jp-merge-local-color1: rgb(31 31 224 / 40%); 15 | } 16 | -------------------------------------------------------------------------------- /testutils/jest-setup-files.js: -------------------------------------------------------------------------------- 1 | /* global globalThis */ 2 | globalThis.DragEvent = class DragEvent {}; 3 | if ( 4 | typeof globalThis.TextDecoder === 'undefined' || 5 | typeof globalThis.TextEncoder === 'undefined' 6 | ) { 7 | const util = require('util'); 8 | globalThis.TextDecoder = util.TextDecoder; 9 | globalThis.TextEncoder = util.TextEncoder; 10 | } 11 | const fetchMod = (window.fetch = require('node-fetch')); 12 | window.Request = fetchMod.Request; 13 | window.Headers = fetchMod.Headers; 14 | window.Response = fetchMod.Response; 15 | globalThis.Image = window.Image; 16 | window.focus = () => { 17 | /* JSDom throws "Not Implemented" */ 18 | }; 19 | window.document.elementFromPoint = (left, top) => document.body; 20 | if (!window.hasOwnProperty('getSelection')) { 21 | // Minimal getSelection() that supports a fake selection 22 | window.getSelection = function getSelection() { 23 | return { 24 | _selection: '', 25 | selectAllChildren: () => { 26 | this._selection = 'foo'; 27 | }, 28 | toString: () => { 29 | const val = this._selection; 30 | this._selection = ''; 31 | return val; 32 | } 33 | }; 34 | }; 35 | } 36 | // Used by xterm.js 37 | window.matchMedia = function (media) { 38 | return { 39 | matches: false, 40 | media, 41 | onchange: () => { 42 | /* empty */ 43 | }, 44 | addEventListener: () => { 45 | /* empty */ 46 | }, 47 | removeEventListener: () => { 48 | /* empty */ 49 | }, 50 | dispatchEvent: () => { 51 | return true; 52 | }, 53 | addListener: () => { 54 | /* empty */ 55 | }, 56 | removeListener: () => { 57 | /* empty */ 58 | } 59 | }; 60 | }; 61 | process.on('unhandledRejection', (error, promise) => { 62 | console.error('Unhandled promise rejection somewhere in tests'); 63 | if (error) { 64 | console.error(error); 65 | const stack = error.stack; 66 | if (stack) { 67 | console.error(stack); 68 | } 69 | } 70 | promise.catch(err => console.error('promise rejected', err)); 71 | }); 72 | if (window.requestIdleCallback === undefined) { 73 | // On Safari, requestIdleCallback is not available, so we use replacement functions for `idleCallbacks` 74 | // See: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#falling_back_to_settimeout 75 | // eslint-disable-next-line @typescript-eslint/ban-types 76 | window.requestIdleCallback = function (handler) { 77 | let startTime = Date.now(); 78 | return setTimeout(function () { 79 | handler({ 80 | didTimeout: false, 81 | timeRemaining: function () { 82 | return Math.max(0, 50.0 - (Date.now() - startTime)); 83 | } 84 | }); 85 | }, 1); 86 | }; 87 | window.cancelIdleCallback = function (id) { 88 | clearTimeout(id); 89 | }; 90 | } 91 | 92 | globalThis.ResizeObserver = require('resize-observer-polyfill'); 93 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "strictNullChecks": true, 21 | "target": "ES2018", 22 | "types": ["resize-observer-browser"] 23 | }, 24 | "include": ["src/**/*"], 25 | "exclude": ["src/__tests__"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "types": ["jest"] 5 | }, 6 | "include": ["src/**/*"], 7 | "exclude": [] 8 | } 9 | -------------------------------------------------------------------------------- /ui-tests/jupyter_server_test_config.py: -------------------------------------------------------------------------------- 1 | """Server configuration for integration tests. 2 | 3 | !! Never use this configuration in production because it 4 | opens the server to the world and provide access to JupyterLab 5 | JavaScript objects through the global window variable. 6 | """ 7 | 8 | import sys 9 | 10 | try: 11 | import jupyter_archive 12 | except ImportError: 13 | print("You must install `jupyter-archive` for the integration tests.") 14 | sys.exit(1) 15 | 16 | from jupyterlab.galata import configure_jupyter_server 17 | 18 | configure_jupyter_server(c) 19 | 20 | # Uncomment to set server log level to debug level 21 | # c.ServerApp.log_level = "DEBUG" 22 | -------------------------------------------------------------------------------- /ui-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyterlab/git-ui-tests", 3 | "version": "1.0.0", 4 | "description": "JupyterLab @jupyterlab/git Integration Tests", 5 | "private": true, 6 | "scripts": { 7 | "start": "jupyter lab --config jupyter_server_test_config.py", 8 | "test": "jlpm playwright test", 9 | "test:update": "jlpm playwright test --update-snapshots" 10 | }, 11 | "devDependencies": { 12 | "@jupyterlab/galata": "^5.0.6", 13 | "@playwright/test": "^1.37.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui-tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for Playwright using default from @jupyterlab/galata 3 | */ 4 | const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); 5 | 6 | module.exports = { 7 | ...baseConfig, 8 | webServer: { 9 | command: 'jlpm start', 10 | url: 'http://localhost:8888/lab', 11 | timeout: 120 * 1000, 12 | reuseExistingServer: !process.env.CI 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /ui-tests/tests/add-tag.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, galata, test } from '@jupyterlab/galata'; 2 | import path from 'path'; 3 | import { extractFile } from './utils'; 4 | 5 | const baseRepositoryPath = 'test-repository-dirty.tar.gz'; 6 | test.use({ autoGoto: false, mockSettings: galata.DEFAULT_SETTINGS }); 7 | 8 | test.describe('Add tag', () => { 9 | test.beforeEach(async ({ page, request, tmpPath }) => { 10 | await extractFile( 11 | request, 12 | path.resolve(__dirname, 'data', baseRepositoryPath), 13 | path.join(tmpPath, 'repository.tar.gz') 14 | ); 15 | 16 | // URL for merge conflict example repository 17 | await page.goto(`tree/${tmpPath}/test-repository`); 18 | 19 | await page.sidebar.openTab('jp-git-sessions'); 20 | }); 21 | 22 | test('should show Add Tag command on commit from history sidebar', async ({ 23 | page 24 | }) => { 25 | await page.click('button:has-text("History")'); 26 | 27 | const commits = page.locator('li[title="View commit details"]'); 28 | 29 | expect(await commits.count()).toBeGreaterThanOrEqual(2); 30 | 31 | // Right click the first commit to open the context menu, with the add tag command 32 | await page.getByText('master changes').click({ button: 'right' }); 33 | 34 | expect(await page.getByRole('menuitem', { name: 'Add Tag' })).toBeTruthy(); 35 | }); 36 | 37 | test('should open new tag dialog box', async ({ page }) => { 38 | await page.click('button:has-text("History")'); 39 | 40 | const commits = page.locator('li[title="View commit details"]'); 41 | 42 | expect(await commits.count()).toBeGreaterThanOrEqual(2); 43 | 44 | // Right click the first commit to open the context menu, with the add tag command 45 | await page.getByText('master changes').click({ button: 'right' }); 46 | 47 | // Click on the add tag command 48 | await page.getByRole('menuitem', { name: 'Add Tag' }).click(); 49 | 50 | expect(page.getByText('Create a Tag')).toBeTruthy(); 51 | }); 52 | 53 | test('should create new tag pointing to selected commit', async ({ 54 | page 55 | }) => { 56 | await page.click('button:has-text("History")'); 57 | 58 | const commits = page.locator('li[title="View commit details"]'); 59 | expect(await commits.count()).toBeGreaterThanOrEqual(2); 60 | 61 | // Right click the first commit to open the context menu, with the add tag command 62 | await page.getByText('master changes').click({ button: 'right' }); 63 | 64 | // Click on the add tag command 65 | await page.getByRole('menuitem', { name: 'Add Tag' }).click(); 66 | 67 | // Create a test tag 68 | await page.getByRole('textbox').fill('testTag'); 69 | await page.getByRole('button', { name: 'Create Tag' }).click(); 70 | 71 | expect(await page.getByText('testTag')).toBeTruthy(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /ui-tests/tests/commit-diff.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jupyterlab/galata'; 2 | import path from 'path'; 3 | import { extractFile } from './utils'; 4 | 5 | const baseRepositoryPath = 'test-repository.tar.gz'; 6 | test.use({ autoGoto: false }); 7 | 8 | test.describe('Commits diff', () => { 9 | test.beforeEach(async ({ page, request, tmpPath }) => { 10 | await extractFile( 11 | request, 12 | path.resolve(__dirname, 'data', baseRepositoryPath), 13 | path.join(tmpPath, 'repository.tar.gz') 14 | ); 15 | 16 | // URL for merge conflict example repository 17 | await page.goto(`tree/${tmpPath}/test-repository`); 18 | }); 19 | 20 | test('should display commits diff from history', async ({ page }) => { 21 | await page.sidebar.openTab('jp-git-sessions'); 22 | await page.click('button:has-text("History")'); 23 | const commits = page.locator('li[title="View commit details"]'); 24 | 25 | expect(await commits.count()).toBeGreaterThanOrEqual(2); 26 | 27 | await commits.last().locator('button[title="Select for compare"]').click(); 28 | 29 | expect( 30 | await page.waitForSelector('text=No challenger commit selected.') 31 | ).toBeTruthy(); 32 | await commits 33 | .first() 34 | .locator('button[title="Compare with selected"]') 35 | .click(); 36 | 37 | expect(await page.waitForSelector('text=Changed')).toBeTruthy(); 38 | }); 39 | 40 | test('should display diff from single file history', async ({ page }) => { 41 | await page.sidebar.openTab('filebrowser'); 42 | await page.getByText('example.ipynb').click({ 43 | button: 'right' 44 | }); 45 | await page.getByRole('menu').getByText('Git').hover(); 46 | await page.click('#jp-contextmenu-git >> text=History'); 47 | 48 | await page.waitForSelector('#jp-git-sessions >> ol >> text=example.ipynb'); 49 | 50 | const commits = page.locator('li[title="View file changes"]'); 51 | 52 | expect(await commits.count()).toBeGreaterThanOrEqual(2); 53 | 54 | await commits.last().locator('button[title="Select for compare"]').click(); 55 | await commits 56 | .first() 57 | .locator('button[title="Compare with selected"]') 58 | .click(); 59 | 60 | await expect( 61 | page.locator('.nbdime-Widget >> .jp-git-diff-banner') 62 | ).toHaveText( 63 | /79fe96219f6eaec1ae607c7c8d21d5b269a6dd29[\n\s]+51fe1f8995113884e943201341a5d5b7a1393e24/ 64 | ); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /ui-tests/tests/commit.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jupyterlab/galata'; 2 | import path from 'path'; 3 | import { extractFile } from './utils'; 4 | 5 | const baseRepositoryPath = 'test-repository.tar.gz'; 6 | test.use({ autoGoto: false }); 7 | 8 | test.describe('Commit', () => { 9 | test.beforeEach(async ({ page, request, tmpPath }) => { 10 | await extractFile( 11 | request, 12 | path.resolve(__dirname, 'data', baseRepositoryPath), 13 | path.join(tmpPath, 'repository.tar.gz') 14 | ); 15 | 16 | // URL for merge conflict example repository 17 | await page.goto(`tree/${tmpPath}/test-repository`); 18 | }); 19 | 20 | test('should commit a change', async ({ page }) => { 21 | await page 22 | .getByRole('listitem', { name: 'Name: another_file.txt' }) 23 | .dblclick(); 24 | await page 25 | .getByLabel('another_file.txt') 26 | .getByRole('textbox') 27 | .fill('My new content'); 28 | await page.keyboard.press('Control+s'); 29 | 30 | await page.getByRole('tab', { name: 'Git' }).click(); 31 | await page.getByTitle('another_file.txt • Modified').hover(); 32 | await page.getByRole('button', { name: 'Stage this change' }).click(); 33 | 34 | await page 35 | .getByPlaceholder('Summary (Ctrl+Enter to commit)') 36 | .fill('My new commit'); 37 | 38 | await page.getByRole('button', { name: 'Commit', exact: true }).click(); 39 | 40 | await page.getByRole('tab', { name: 'History' }).click(); 41 | 42 | await expect(page.getByText('My new commit')).toBeVisible(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /ui-tests/tests/data/test-repository-dirty.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/ui-tests/tests/data/test-repository-dirty.tar.gz -------------------------------------------------------------------------------- /ui-tests/tests/data/test-repository-merge-commits.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/ui-tests/tests/data/test-repository-merge-commits.tar.gz -------------------------------------------------------------------------------- /ui-tests/tests/data/test-repository-stash.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/ui-tests/tests/data/test-repository-stash.tar.gz -------------------------------------------------------------------------------- /ui-tests/tests/data/test-repository.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/ui-tests/tests/data/test-repository.tar.gz -------------------------------------------------------------------------------- /ui-tests/tests/image-diff.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jupyterlab/galata'; 2 | import path from 'path'; 3 | import { extractFile } from './utils'; 4 | 5 | const baseRepositoryPath = 'test-repository.tar.gz'; 6 | test.use({ autoGoto: false }); 7 | 8 | test.describe('Image diff', () => { 9 | test.beforeEach(async ({ page, request, tmpPath }) => { 10 | await extractFile( 11 | request, 12 | path.resolve(__dirname, 'data', baseRepositoryPath), 13 | path.join(tmpPath, 'repository.tar.gz') 14 | ); 15 | 16 | // URL for merge conflict example repository 17 | await page.goto(`tree/${tmpPath}/test-repository`); 18 | }); 19 | 20 | test('should display image diff from history', async ({ page }) => { 21 | await page.sidebar.openTab('jp-git-sessions'); 22 | await page.click('button:has-text("History")'); 23 | const commits = page.getByTitle('View commit details'); 24 | 25 | await commits.first().click(); 26 | 27 | await page 28 | .getByTitle('git_workflow.jpg') 29 | .getByRole('button', { name: 'View file changes' }) 30 | .click(); 31 | 32 | expect 33 | .soft(await page.locator('.jp-git-image-diff').screenshot()) 34 | .toMatchSnapshot('jpeg_diff.png'); 35 | 36 | await page 37 | .getByTitle('jupyter.png') 38 | .getByRole('button', { name: 'View file changes' }) 39 | .click(); 40 | 41 | expect( 42 | await page.locator('.jp-git-image-diff').last().screenshot() 43 | ).toMatchSnapshot('png_diff.png'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /ui-tests/tests/image-diff.spec.ts-snapshots/jpeg-diff-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/ui-tests/tests/image-diff.spec.ts-snapshots/jpeg-diff-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/image-diff.spec.ts-snapshots/png-diff-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-git/c5585fdbb1c0107a719a942d0c77d913fa4ef600/ui-tests/tests/image-diff.spec.ts-snapshots/png-diff-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/merge-commit.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, galata, test } from '@jupyterlab/galata'; 2 | import path from 'path'; 3 | import { extractFile } from './utils'; 4 | 5 | const baseRepositoryPath = 'test-repository-merge-commits.tar.gz'; 6 | test.use({ autoGoto: false, mockSettings: galata.DEFAULT_SETTINGS }); 7 | 8 | test.describe('Merge commit tests', () => { 9 | test.beforeEach(async ({ page, request, tmpPath }) => { 10 | await extractFile( 11 | request, 12 | path.resolve(__dirname, 'data', baseRepositoryPath), 13 | path.join(tmpPath, 'repository.tar.gz') 14 | ); 15 | 16 | // URL for merge commit example repository 17 | await page.goto(`tree/${tmpPath}/test-repository-merge-commits`); 18 | 19 | await page.sidebar.openTab('jp-git-sessions'); 20 | 21 | await page.getByRole('tab', { name: 'History' }).click(); 22 | }); 23 | 24 | test('should correctly display num files changed, insertions, and deletions', async ({ 25 | page 26 | }) => { 27 | const mergeCommit = page.getByText("Merge branch 'sort-names'"); 28 | 29 | await mergeCommit.click(); 30 | 31 | const filesChanged = mergeCommit.getByTitle('# Files Changed'); 32 | const insertions = mergeCommit.getByTitle('# Insertions'); 33 | const deletions = mergeCommit.getByTitle('# Deletions'); 34 | 35 | await filesChanged.waitFor(); 36 | 37 | expect(await filesChanged.innerText()).toBe('3'); 38 | expect(await insertions.innerText()).toBe('18240'); 39 | expect(await deletions.innerText()).toBe('18239'); 40 | }); 41 | 42 | test('should correctly display files changed', async ({ page }) => { 43 | const mergeCommit = page.getByText("Merge branch 'sort-names'"); 44 | 45 | await mergeCommit.click(); 46 | 47 | const helloWorldFile = page.getByRole('listitem', { 48 | name: 'hello-world.py' 49 | }); 50 | const namesFile = page.getByRole('listitem', { name: 'names.txt' }); 51 | const newFile = page.getByRole('listitem', { name: 'new-file.txt' }); 52 | 53 | expect(helloWorldFile).toBeTruthy(); 54 | expect(namesFile).toBeTruthy(); 55 | expect(newFile).toBeTruthy(); 56 | }); 57 | 58 | test('should diff file after clicking', async ({ page }) => { 59 | const mergeCommit = page.getByText("Merge branch 'sort-names'"); 60 | 61 | await mergeCommit.click(); 62 | 63 | const file = page.getByRole('listitem', { name: 'hello-world.py' }); 64 | await file.click(); 65 | 66 | await page 67 | .getByRole('tab', { name: 'hello-world.py' }) 68 | .waitFor({ state: 'visible' }); 69 | 70 | await expect(page.locator('.jp-git-diff-root')).toBeVisible(); 71 | }); 72 | 73 | test('should revert merge commit', async ({ page }) => { 74 | const mergeCommit = page.getByText("Merge branch 'sort-names'", { 75 | exact: true 76 | }); 77 | 78 | await mergeCommit.click(); 79 | await page 80 | .getByRole('button', { name: 'Revert changes introduced by this commit' }) 81 | .click(); 82 | 83 | const dialog = page.getByRole('dialog'); 84 | await dialog.waitFor({ state: 'visible' }); 85 | 86 | expect(dialog).toBeTruthy(); 87 | 88 | await dialog.getByRole('button', { name: 'Submit' }).click(); 89 | await dialog.waitFor({ state: 'detached' }); 90 | 91 | const revertMergeCommit = page 92 | .locator('#jp-git-sessions') 93 | .getByText("Revert 'Merge branch 'sort-names''"); 94 | 95 | await expect(revertMergeCommit).toBeVisible(); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /ui-tests/tests/merge-conflict.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, galata, test } from '@jupyterlab/galata'; 2 | import path from 'path'; 3 | import { extractFile } from './utils'; 4 | 5 | const baseRepositoryPath = 'test-repository.tar.gz'; 6 | test.use({ autoGoto: false, mockSettings: galata.DEFAULT_SETTINGS }); 7 | 8 | test.describe('Merge conflict tests', () => { 9 | test.beforeEach(async ({ page, request, tmpPath }) => { 10 | await extractFile( 11 | request, 12 | path.resolve(__dirname, 'data', baseRepositoryPath), 13 | path.join(tmpPath, 'repository.tar.gz') 14 | ); 15 | 16 | // URL for merge conflict example repository 17 | await page.goto(`tree/${tmpPath}/test-repository`); 18 | 19 | await page.sidebar.openTab('jp-git-sessions'); 20 | 21 | await page.getByRole('button', { name: 'Current Branch master' }).click(); 22 | 23 | // Click on a-branch merge button 24 | await page.locator('text=a-branch').hover(); 25 | await page 26 | .getByRole('button', { 27 | name: 'Merge this branch into the current one', 28 | exact: true 29 | }) 30 | .click(); 31 | 32 | // Hide branch panel 33 | await page.getByRole('button', { name: 'Current Branch master' }).click(); 34 | 35 | // Force refresh 36 | await page 37 | .getByRole('button', { 38 | name: 'Refresh the repository to detect local and remote changes' 39 | }) 40 | .click(); 41 | }); 42 | 43 | test('should diff conflicted text file', async ({ page }) => { 44 | await page 45 | .getByTitle('file.txt • Conflicted', { exact: true }) 46 | .click({ clickCount: 2 }); 47 | await page.waitForSelector( 48 | '.jp-git-diff-parent-widget[id^="Current-Incoming"] .jp-spinner', 49 | { state: 'detached' } 50 | ); 51 | await page.waitForSelector('.jp-git-diff-root'); 52 | 53 | // Verify 3-way merge view appears 54 | const banner = page.locator('.jp-git-merge-banner'); 55 | await expect(banner).toHaveText(/Current/); 56 | await expect(banner).toHaveText(/Result/); 57 | await expect(banner).toHaveText(/Incoming/); 58 | 59 | const mergeDiff = page.locator('.cm-merge-3pane'); 60 | await expect(mergeDiff).toBeVisible(); 61 | }); 62 | 63 | test('should diff conflicted notebook file', async ({ page }) => { 64 | await page.getByTitle('example.ipynb • Conflicted').click({ 65 | clickCount: 2 66 | }); 67 | await page.waitForSelector( 68 | '.jp-git-diff-parent-widget[id^="Current-Incoming"] .jp-spinner', 69 | { state: 'detached' } 70 | ); 71 | await page.waitForSelector('.jp-git-diff-root'); 72 | 73 | // Verify notebook merge view appears 74 | const banner = page.locator('.jp-git-merge-banner'); 75 | await expect(banner).toHaveText(/Current/); 76 | await expect(banner).toHaveText(/Incoming/); 77 | 78 | const mergeDiff = page.locator('.jp-Notebook-merge'); 79 | await expect(mergeDiff).toBeVisible(); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /ui-tests/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { galata } from '@jupyterlab/galata'; 2 | import { APIRequestContext } from '@playwright/test'; 3 | 4 | export async function extractFile( 5 | request: APIRequestContext, 6 | filePath: string, 7 | destination: string 8 | ): Promise { 9 | const contents = galata.newContentsHelper(request); 10 | await contents.uploadFile(filePath, destination); 11 | 12 | await request.get(`/extract-archive/${destination}`); 13 | 14 | await contents.deleteFile(destination); 15 | } 16 | --------------------------------------------------------------------------------