{
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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 | * @param serverSettings Optional server connection settings; if not provided, uses ServerConnection.makeSettings()
24 | * @returns The response body interpreted as JSON
25 | *
26 | * @throws {Git.GitResponseError} If the server response is not ok
27 | * @throws {ServerConnection.NetworkError} If the request cannot be made
28 | */
29 | export async function requestAPI(
30 | endPoint = '',
31 | method = 'GET',
32 | body: Partial | null = null,
33 | namespace = 'git',
34 | serverSettings?: ServerConnection.ISettings
35 | ): Promise {
36 | // Make request to Jupyter API
37 | const settings = serverSettings ?? ServerConnection.makeSettings();
38 | const requestUrl = URLExt.join(
39 | settings.baseUrl,
40 | namespace, // API Namespace
41 | endPoint
42 | );
43 |
44 | const init: RequestInit = {
45 | method,
46 | body: body ? JSON.stringify(body) : undefined
47 | };
48 |
49 | let response: Response;
50 | try {
51 | response = await ServerConnection.makeRequest(requestUrl, init, settings);
52 | } catch (error: any) {
53 | throw new ServerConnection.NetworkError(error);
54 | }
55 |
56 | let data: any = await response.text();
57 | let isJSON = false;
58 | if (data.length > 0) {
59 | try {
60 | data = JSON.parse(data);
61 | isJSON = true;
62 | } catch (error) {
63 | console.log('Not a JSON response body.', response);
64 | }
65 | }
66 |
67 | if (!response.ok) {
68 | if (isJSON) {
69 | const { message, traceback, ...json } = data;
70 | throw new Git.GitResponseError(
71 | response,
72 | message ||
73 | `Invalid response: ${response.status} ${response.statusText}`,
74 | traceback || '',
75 | json
76 | );
77 | } else {
78 | throw new Git.GitResponseError(response, data);
79 | }
80 | }
81 |
82 | return data;
83 | }
84 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/__tests__/utils.ts:
--------------------------------------------------------------------------------
1 | import { ServerConnection } from '@jupyterlab/services';
2 | import { ReadonlyJSONObject } from '@lumino/coreutils';
3 | import { Git } from '../tokens';
4 |
5 | export interface IMockedResponse {
6 | // Response body
7 | body?: (body: any) => ReadonlyJSONObject | null;
8 | // Response status code
9 | status?: number;
10 | }
11 |
12 | export interface IMockedResponses {
13 | // Folder path in URI; default = DEFAULT_REPOSITORY_PATH
14 | path?: string | null;
15 | // Endpoint
16 | responses?: {
17 | [endpoint: string]: IMockedResponse;
18 | };
19 | }
20 |
21 | export const DEFAULT_REPOSITORY_PATH = 'path/to/repo';
22 |
23 | export const defaultMockedResponses: {
24 | [endpoint: string]: IMockedResponse;
25 | } = {
26 | branch: {
27 | body: () => ({
28 | code: 0,
29 | branches: [],
30 | current_branch: { name: '' }
31 | })
32 | },
33 | changed_files: {
34 | body: () => ({
35 | code: 0,
36 | files: []
37 | })
38 | },
39 | show_prefix: {
40 | body: () => ({
41 | code: 0,
42 | path: ''
43 | })
44 | },
45 | stash: {
46 | body: () => ({
47 | code: 0,
48 | stashes: []
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 | serverSettings?: ServerConnection.ISettings
73 | ) => Promise {
74 | const mockedImplementation = (
75 | url?: string,
76 | method?: string,
77 | body?: ReadonlyJSONObject | null,
78 | namespace?: string,
79 | serverSettings?: ServerConnection.ISettings
80 | ) => {
81 | mockedResponses = mockedResponses ?? {};
82 | const path = mockedResponses.path ?? DEFAULT_REPOSITORY_PATH;
83 | const responses = mockedResponses.responses ?? defaultMockedResponses;
84 | url = (url ?? '').replace(new RegExp(`^${path}/`), ''); // Remove path + '/'
85 | const reply = responses[url + method] ?? responses[url];
86 | if (reply) {
87 | if (reply.status) {
88 | throw new Git.GitResponseError(
89 | new Response(null, {
90 | status: reply.status
91 | }),
92 | '',
93 | '',
94 | reply.body ? reply.body(body) : {}
95 | );
96 | } else {
97 | return Promise.resolve(reply.body?.(body));
98 | }
99 | } else {
100 | throw new Git.GitResponseError(
101 | new Response(`{"message": "No mock implementation for ${url}."}`, {
102 | status: 404
103 | })
104 | );
105 | }
106 | };
107 | return mockedImplementation;
108 | }
109 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/__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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------