diff --git a/build/ci/vscode-python-ci.yaml b/build/ci/vscode-python-ci.yaml index f2939bf55565..187f1f2a796a 100644 --- a/build/ci/vscode-python-ci.yaml +++ b/build/ci/vscode-python-ci.yaml @@ -265,8 +265,7 @@ stages: - stage: Smoke - dependsOn: - - Build + dependsOn: [] jobs: - job: 'Smoke' dependsOn: [] diff --git a/build/ci/vscode-python-pr-validation.yaml b/build/ci/vscode-python-pr-validation.yaml index a5a25a53beb2..593947f75746 100644 --- a/build/ci/vscode-python-pr-validation.yaml +++ b/build/ci/vscode-python-pr-validation.yaml @@ -93,8 +93,7 @@ stages: - template: templates/test_phases.yml - stage: Smoke - dependsOn: - - Build + dependsOn: [] jobs: - job: 'Smoke' dependsOn: [] diff --git a/build/webpack/webpack.extension.config.js b/build/webpack/webpack.extension.config.js index b19caa9f6a7d..4cedc7c07595 100644 --- a/build/webpack/webpack.extension.config.js +++ b/build/webpack/webpack.extension.config.js @@ -68,6 +68,7 @@ const config = { onEnd: [ { copy: [ + { source: './node_modules/fontkit/*.trie', destination: './out/client/node_modules' }, { source: './node_modules/pdfkit/js/data/*.*', destination: './out/client/node_modules/data' }, { source: './node_modules/pdfkit/js/pdfkit.js', destination: './out/client/node_modules/' } ] diff --git a/build/webpack/webpack.extension.config.ts b/build/webpack/webpack.extension.config.ts index 9b565d99e369..c7e447a27d5d 100644 --- a/build/webpack/webpack.extension.config.ts +++ b/build/webpack/webpack.extension.config.ts @@ -75,6 +75,7 @@ const config: Configuration = { onEnd: [ { copy: [ + { source: './node_modules/fontkit/*.trie', destination: './out/client/node_modules' }, { source: './node_modules/pdfkit/js/data/*.*', destination: './out/client/node_modules/data' }, { source: './node_modules/pdfkit/js/pdfkit.js', destination: './out/client/node_modules/' } ] diff --git a/news/1 Enhancements/4441.md b/news/1 Enhancements/4441.md new file mode 100644 index 000000000000..b3671783354c --- /dev/null +++ b/news/1 Enhancements/4441.md @@ -0,0 +1 @@ +Support other variables for notebookFileRoot besides ${workspaceRoot}. Specifically allow things like ${fileDirName} so that the dir of the first file run in the interactive window is used for the current directory. diff --git a/news/1 Enhancements/7800.md b/news/1 Enhancements/7800.md new file mode 100644 index 000000000000..647a10db36eb --- /dev/null +++ b/news/1 Enhancements/7800.md @@ -0,0 +1 @@ +Add command palette commands for native editor (run all cells, run selected cell, add new cell). And remove interactive window commands from contexts where they don't apply. \ No newline at end of file diff --git a/news/1 Enhancements/7831.md b/news/1 Enhancements/7831.md new file mode 100644 index 000000000000..cd8ca8a6c628 --- /dev/null +++ b/news/1 Enhancements/7831.md @@ -0,0 +1 @@ +Added ability to auto-save chagnes made to the notebook. diff --git a/news/2 Fixes/7137.md b/news/2 Fixes/7137.md new file mode 100644 index 000000000000..952440c7f342 --- /dev/null +++ b/news/2 Fixes/7137.md @@ -0,0 +1 @@ +Fix regression to allow connection to servers with no token and no password and add functional test for this scenario \ No newline at end of file diff --git a/news/2 Fixes/7483.md b/news/2 Fixes/7483.md new file mode 100644 index 000000000000..c8b22aada88f --- /dev/null +++ b/news/2 Fixes/7483.md @@ -0,0 +1 @@ +Perf improvements for opening notebooks with more than 100 cells. diff --git a/news/2 Fixes/7569.md b/news/2 Fixes/7569.md new file mode 100644 index 000000000000..20e5e8a45853 --- /dev/null +++ b/news/2 Fixes/7569.md @@ -0,0 +1 @@ +Fix jupyter server startup hang when xeus-cling kernel is installed. diff --git a/news/2 Fixes/7624.md b/news/2 Fixes/7624.md new file mode 100644 index 000000000000..1875f4ca313b --- /dev/null +++ b/news/2 Fixes/7624.md @@ -0,0 +1 @@ +Make interactive window and native take their fontSize and fontFamily from the settings in VS Code. diff --git a/news/2 Fixes/7638.md b/news/2 Fixes/7638.md new file mode 100644 index 000000000000..dea499b65438 --- /dev/null +++ b/news/2 Fixes/7638.md @@ -0,0 +1 @@ +Fix a hang in the Interactive window when connecting guest to host after the host has already started the interactive window. \ No newline at end of file diff --git a/news/2 Fixes/7674.md b/news/2 Fixes/7674.md new file mode 100644 index 000000000000..69467e4d436b --- /dev/null +++ b/news/2 Fixes/7674.md @@ -0,0 +1,2 @@ +Change the default cell marker to '# %%' instead of '#%%' to prevent linter errors in python files with markers. +Also added a new setting to change this - 'python.dataScience.defaultCellMarker'. diff --git a/news/2 Fixes/7688.md b/news/2 Fixes/7688.md new file mode 100644 index 000000000000..9c7cea856132 --- /dev/null +++ b/news/2 Fixes/7688.md @@ -0,0 +1 @@ +When there's no workspace open, use the directory of the opened file as the root directory for a jupyter session. diff --git a/news/2 Fixes/7802.md b/news/2 Fixes/7802.md new file mode 100644 index 000000000000..969f2f7feccd --- /dev/null +++ b/news/2 Fixes/7802.md @@ -0,0 +1 @@ +Fix selection and focus not updating when clicking around in a notebook editor. diff --git a/news/2 Fixes/7820.md b/news/2 Fixes/7820.md new file mode 100644 index 000000000000..74f0775bdb7d --- /dev/null +++ b/news/2 Fixes/7820.md @@ -0,0 +1 @@ +Fix add new cell buttons in the notebook editor to give the new cell focus. diff --git a/news/2 Fixes/7844.md b/news/2 Fixes/7844.md new file mode 100644 index 000000000000..f49616593f8a --- /dev/null +++ b/news/2 Fixes/7844.md @@ -0,0 +1 @@ +Prevent updates to the cell text when cell execution of the same cell has commenced or completed. diff --git a/news/2 Fixes/7851.md b/news/2 Fixes/7851.md new file mode 100644 index 000000000000..d45e641a6d22 --- /dev/null +++ b/news/2 Fixes/7851.md @@ -0,0 +1 @@ +Hide the parameters intellisense widget in the `Notebook Editor` when it is not longer required. diff --git a/news/2 Fixes/7888.md b/news/2 Fixes/7888.md new file mode 100644 index 000000000000..c1714788317c --- /dev/null +++ b/news/2 Fixes/7888.md @@ -0,0 +1 @@ +Allow the "Create New Blank Jupyter Notebook" command to be run when the python extension is not loaded yet. \ No newline at end of file diff --git a/news/2 Fixes/7899.md b/news/2 Fixes/7899.md new file mode 100644 index 000000000000..7fe6b48d5688 --- /dev/null +++ b/news/2 Fixes/7899.md @@ -0,0 +1 @@ +Ensure the `*.trie` files related to `font kit` npm module are copied into the output directory as part of the `Webpack` bundling operation. diff --git a/news/2 Fixes/7904.md b/news/2 Fixes/7904.md new file mode 100644 index 000000000000..6680937198b7 --- /dev/null +++ b/news/2 Fixes/7904.md @@ -0,0 +1 @@ +CTRL+S is not saving a Notebook file. diff --git a/news/2 Fixes/7905.md b/news/2 Fixes/7905.md new file mode 100644 index 000000000000..096640f862c8 --- /dev/null +++ b/news/2 Fixes/7905.md @@ -0,0 +1 @@ +When automatically opening the `Notebook Editor`, then ignore uris that do not have a `file` scheme diff --git a/news/2 Fixes/7960.md b/news/2 Fixes/7960.md new file mode 100644 index 000000000000..a341718fdcb0 --- /dev/null +++ b/news/2 Fixes/7960.md @@ -0,0 +1 @@ +Minimize the changes to an ipynb file when saving - preserve metadata and spacing. diff --git a/news/2 Fixes/8009.md b/news/2 Fixes/8009.md new file mode 100644 index 000000000000..045df2aba2ce --- /dev/null +++ b/news/2 Fixes/8009.md @@ -0,0 +1 @@ +Fix intellisense popping up in the wrong spot when first typing in a cell. diff --git a/news/2 Fixes/8010.md b/news/2 Fixes/8010.md new file mode 100644 index 000000000000..a0b0378621d2 --- /dev/null +++ b/news/2 Fixes/8010.md @@ -0,0 +1 @@ +Fix python.dataScience.maxOutputSize to be honored again. diff --git a/news/2 Fixes/8045.md b/news/2 Fixes/8045.md new file mode 100644 index 000000000000..aa1a10ecd523 --- /dev/null +++ b/news/2 Fixes/8045.md @@ -0,0 +1 @@ +Fix markdown disappearing after editing and hitting the escape key. diff --git a/news/3 Code Health/7369.md b/news/3 Code Health/7369.md new file mode 100644 index 000000000000..58e65e615c03 --- /dev/null +++ b/news/3 Code Health/7369.md @@ -0,0 +1 @@ +Add functional tests for notebook editor's use of the variable list. diff --git a/news/3 Code Health/7372.md b/news/3 Code Health/7372.md new file mode 100644 index 000000000000..332da98db687 --- /dev/null +++ b/news/3 Code Health/7372.md @@ -0,0 +1 @@ +More functional tests for the notebook editor. diff --git a/news/3 Code Health/7832.md b/news/3 Code Health/7832.md new file mode 100644 index 000000000000..ee4faa776756 --- /dev/null +++ b/news/3 Code Health/7832.md @@ -0,0 +1 @@ +Update version of `@types/vscode`. diff --git a/news/3 Code Health/7834.md b/news/3 Code Health/7834.md new file mode 100644 index 000000000000..3bd2421b2b27 --- /dev/null +++ b/news/3 Code Health/7834.md @@ -0,0 +1 @@ +Use `Webview.asWebviewUri` to generate a URI for use in the `Webview Panel` instead of hardcoding the resource `vscode-resource`. diff --git a/package-lock.json b/package-lock.json index 6a0b36e78cdb..47ca8b2ec4cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2382,9 +2382,9 @@ } }, "@types/vscode": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.36.0.tgz", - "integrity": "sha512-SbHR3Q5g/C3N+Ila3KrRf1rSZiyHxWdOZ7X3yFHXzw6HrvRLuVZrxnwEX0lTBMRpH9LkwZdqRTgXW+D075jxkg==", + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.38.0.tgz", + "integrity": "sha512-aGo8LQ4J1YF0T9ORuCO+bhQ5sGR1MXa7VOyOdEP685se3wyQWYUExcdiDi6rvaK61KUwfzzA19JRLDrUbDl7BQ==", "dev": true }, "@types/webpack": { @@ -2622,11 +2622,6 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, - "JSONSelect": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/JSONSelect/-/JSONSelect-0.4.0.tgz", - "integrity": "sha1-oI7cxn6z/L6Z7WMIVTRKDPKCu40=" - }, "JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -2637,11 +2632,6 @@ "through": ">=2.2.7 <3" } }, - "JSV": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz", - "integrity": "sha1-0Hf2glVx+CEy+d/67Vh7QCn+/1c=" - }, "abab": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.0.tgz", @@ -5434,14 +5424,6 @@ "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", "dev": true }, - "cjson": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/cjson/-/cjson-0.3.0.tgz", - "integrity": "sha1-5kObkHA9MS/24iJAl76pLOPQKhQ=", - "requires": { - "jsonlint": "1.6.0" - } - }, "class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -7316,6 +7298,11 @@ "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", "dev": true }, + "detect-indent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz", + "integrity": "sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==" + }, "detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -7646,11 +7633,6 @@ "object.defaults": "^1.1.0" } }, - "ebnf-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/ebnf-parser/-/ebnf-parser-0.1.10.tgz", - "integrity": "sha1-zR9rpHfFY4xAyX7ZtXLbW6tdgzE=" - }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -8040,33 +8022,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, - "escodegen": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.3.3.tgz", - "integrity": "sha1-8CQBb1qI4Eb9EgBQVek5gC5sXyM=", - "requires": { - "esprima": "~1.1.1", - "estraverse": "~1.5.0", - "esutils": "~1.0.0", - "source-map": "~0.1.33" - }, - "dependencies": { - "esprima": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.1.1.tgz", - "integrity": "sha1-W28VR/TRAuZw4UDFCb5ncdautUk=" - }, - "source-map": { - "version": "0.1.43", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", - "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", - "optional": true, - "requires": { - "amdefine": ">=0.0.4" - } - } - } - }, "eslint-scope": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", @@ -11745,37 +11700,6 @@ "is-object": "^1.0.1" } }, - "jison": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/jison/-/jison-0.4.18.tgz", - "integrity": "sha512-FKkCiJvozgC7VTHhMJ00a0/IApSxhlGsFIshLW6trWJ8ONX2TQJBBz6DlcO1Gffy4w9LT+uL+PA+CVnUSJMF7w==", - "requires": { - "JSONSelect": "0.4.0", - "cjson": "0.3.0", - "ebnf-parser": "0.1.10", - "escodegen": "1.3.x", - "esprima": "1.1.x", - "jison-lex": "0.3.x", - "lex-parser": "~0.1.3", - "nomnom": "1.5.2" - }, - "dependencies": { - "esprima": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.1.1.tgz", - "integrity": "sha1-W28VR/TRAuZw4UDFCb5ncdautUk=" - } - } - }, - "jison-lex": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/jison-lex/-/jison-lex-0.3.4.tgz", - "integrity": "sha1-gcoo2E+ESZ36jFlNzePYo/Jux6U=", - "requires": { - "lex-parser": "0.1.x", - "nomnom": "1.5.2" - } - }, "jquery": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", @@ -12044,15 +11968,6 @@ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" }, - "jsonlint": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/jsonlint/-/jsonlint-1.6.0.tgz", - "integrity": "sha1-iKpGvCiaesk7tGyuLVihh6m7SUo=", - "requires": { - "JSV": ">= 4.0.x", - "nomnom": ">= 1.5.x" - } - }, "jsonparse": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.2.0.tgz", @@ -12243,11 +12158,6 @@ "type-check": "~0.3.2" } }, - "lex-parser": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/lex-parser/-/lex-parser-0.1.4.tgz", - "integrity": "sha1-ZMTwJfF/1Tv7RXY/rrFvAVp0dVA=" - }, "liftoff": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz", @@ -13869,27 +13779,6 @@ "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.8.2.tgz", "integrity": "sha512-zwP2F/R28Oqtl0gOLItk5QjJ6jEU8XO4kaUMgeqvCyXPgdCZlm8T/5qLMiNy+moJCBCiMQAaX7aVMRhT0t2vkQ==" }, - "nomnom": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.5.2.tgz", - "integrity": "sha1-9DRUSKhTz71cDSYyDyR3qwUm/i8=", - "requires": { - "colors": "0.5.x", - "underscore": "1.1.x" - }, - "dependencies": { - "colors": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", - "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=" - }, - "underscore": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.1.7.tgz", - "integrity": "sha1-QLq4S60Z0jAJbo1u9ii/8FXYPbA=" - } - } - }, "nopt": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", @@ -19751,6 +19640,13 @@ "requires": { "vscode-languageserver-protocol": "3.14.1", "vscode-uri": "^1.0.6" + }, + "dependencies": { + "vscode-uri": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-1.0.8.tgz", + "integrity": "sha512-obtSWTlbJ+a+TFRYGaUumtVwb+InIUVI0Lu0VBUAPmj2cU5JutEXg3xUE0c2J5Tcy7h2DEKVJBFi+Y9ZSFzzPQ==" + } } }, "vscode-languageserver-protocol": { @@ -19777,11 +19673,6 @@ "https-proxy-agent": "^2.2.1" } }, - "vscode-uri": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-1.0.8.tgz", - "integrity": "sha512-obtSWTlbJ+a+TFRYGaUumtVwb+InIUVI0Lu0VBUAPmj2cU5JutEXg3xUE0c2J5Tcy7h2DEKVJBFi+Y9ZSFzzPQ==" - }, "vsls": { "version": "0.3.1291", "resolved": "https://registry.npmjs.org/vsls/-/vsls-0.3.1291.tgz", diff --git a/package.datascience-ui.dependencies.json b/package.datascience-ui.dependencies.json index 41f0d9ff43e2..1916cd4904a5 100644 --- a/package.datascience-ui.dependencies.json +++ b/package.datascience-ui.dependencies.json @@ -85,6 +85,7 @@ "escape-carriage", "extend", "fast-plist", + "immutable", "inherits", "is-alphabetical", "is-alphanumerical", diff --git a/package.json b/package.json index 4e940cda4ee4..2903510eb9ca 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "theme": "dark" }, "engines": { - "vscode": "^1.36.0" + "vscode": "^1.38.0" }, "keywords": [ "python", @@ -72,6 +72,7 @@ "onCommand:python.switchOffInsidersChannel", "onCommand:python.switchToDailyChannel", "onCommand:python.switchToWeeklyChannel", + "onCommand:python.datascience.createnewnotebook", "onCommand:python.datascience.showhistorypane", "onCommand:python.datascience.importnotebook", "onCommand:python.datascience.importnotebookfile", @@ -511,7 +512,7 @@ }, { "command": "python.datascience.notebookeditor.removeallcells", - "title": "%python.command.python.datascience.removeallcells.title%", + "title": "%python.command.python.datascience.notebookeditor.removeallcells.title%", "category": "Python" }, { @@ -524,6 +525,21 @@ "title": "%python.command.python.datascience.restartkernel.title%", "category": "Python" }, + { + "command": "python.datascience.notebookeditor.runallcells", + "title": "%python.command.python.datascience.notebookeditor.runallcells.title%", + "category": "Python" + }, + { + "command": "python.datascience.notebookeditor.runselectedcell", + "title": "%python.command.python.datascience.notebookeditor.runselectedcell.title%", + "category": "Python" + }, + { + "command": "python.datascience.notebookeditor.addcellbelow", + "title": "%python.command.python.datascience.notebookeditor.addcellbelow.title%", + "category": "Python" + }, { "command": "python.datascience.expandallcells", "title": "%python.command.python.datascience.expandallcells.title%", @@ -773,7 +789,25 @@ "command": "python.datascience.runallcells", "title": "%python.command.python.datascience.runallcells.title%", "category": "Python", - "when": "python.datascience.featureenabled" + "when": "python.datascience.hascodecells && python.datascience.featureenabled" + }, + { + "command": "python.datascience.scrolltocell", + "title": "%python.command.python.datascience.scrolltocell.title%", + "category": "Python", + "when": "false" + }, + { + "command": "python.datascience.debugcell", + "title": "%python.command.python.datascience.debugcell.title%", + "category": "Python", + "when": "python.datascience.hascodecells && python.datascience.featureenabled" + }, + { + "command": "python.datascience.runcell", + "title": "%python.command.python.datascience.runcell.title%", + "category": "Python", + "when": "python.datascience.hascodecells && python.datascience.featureenabled" }, { "command": "python.datascience.runFileInteractive", @@ -853,7 +887,7 @@ }, { "command": "python.datascience.notebookeditor.removeallcells", - "title": "%python.command.python.datascience.removeallcells.title%", + "title": "%python.command.python.datascience.notebookeditor.removeallcells.title%", "category": "Python", "when": "python.datascience.havenativecells && python.datascience.featureenabled" }, @@ -869,6 +903,24 @@ "category": "Python", "when": "python.datascience.havenative && python.datascience.featureenabled" }, + { + "command": "python.datascience.notebookeditor.runallcells", + "title": "%python.command.python.datascience.notebookeditor.runallcells.title%", + "category": "Python", + "when": "python.datascience.havenative && python.datascience.featureenabled" + }, + { + "command": "python.datascience.notebookeditor.runselectedcell", + "title": "%python.command.python.datascience.notebookeditor.runselectedcell.title%", + "category": "Python", + "when": "python.datascience.havenative && python.datascience.featureenabled && python.datascience.havecellselected" + }, + { + "command": "python.datascience.notebookeditor.addcellbelow", + "title": "%python.command.python.datascience.notebookeditor.addcellbelow.title%", + "category": "Python", + "when": "python.datascience.havenative && python.datascience.featureenabled" + }, { "command": "python.datascience.expandallcells", "title": "%python.command.python.datascience.expandallcells.title%", @@ -901,13 +953,12 @@ "command": "python.datascience.addcellbelow", "title": "%python.command.python.datascience.addcellbelow.title%", "category": "Python", - "when": "python.datascience.featureenabled" + "when": "python.datascience.hascodecells && python.datascience.featureenabled" }, { "command": "python.datascience.createnewnotebook", "title": "%python.command.python.datascience.createnewnotebook.title%", - "category": "Python", - "when": "python.datascience.featureenabled" + "category": "Python" } ], "view/title": [ @@ -1557,6 +1608,12 @@ "description": "Regular expression used to identify code cells. All code until the next match is considered part of this cell. \nDefaults to '^(#\\s*%%|#\\s*\\|#\\s*In\\[\\d*?\\]|#\\s*In\\[ \\])' if left blank", "scope": "resource" }, + "python.dataScience.defaultCellMarker": { + "type": "string", + "default": "# %%", + "description": "Cell marker used for delineating a cell in a python file.", + "scope": "resource" + }, "python.dataScience.markdownRegularExpression": { "type": "string", "default": "^(#\\s*%%\\s*\\[markdown\\]|#\\s*\\)", @@ -2603,12 +2660,13 @@ "verifyBundle": "gulp verifyBundle" }, "dependencies": { + "@blueprintjs/select": "3.10.0", "@jupyterlab/services": "^3.2.1", "@msrvida/python-program-analysis": "^0.2.6", - "@blueprintjs/select": "3.10.0", "ansi-regex": "^4.1.0", "arch": "^2.1.0", "azure-storage": "^2.10.3", + "detect-indent": "^6.0.0", "diff-match-patch": "^1.0.0", "fast-deep-equal": "^2.0.1", "fs-extra": "^4.0.3", @@ -2618,7 +2676,6 @@ "hash.js": "^1.1.7", "iconv-lite": "^0.4.21", "inversify": "^4.11.1", - "jison": "^0.4.18", "jsonc-parser": "^2.0.3", "less-plugin-inline-urls": "^1.2.0", "line-by-line": "^0.1.6", @@ -2723,7 +2780,7 @@ "@types/tmp": "0.0.33", "@types/untildify": "^3.0.0", "@types/uuid": "^3.4.3", - "@types/vscode": "^1.36.0", + "@types/vscode": "^1.38.0", "@types/webpack-bundle-analyzer": "^2.13.0", "@types/winreg": "^1.2.30", "@types/ws": "^6.0.1", diff --git a/package.nls.json b/package.nls.json index 1c70d070fa5c..701a7f71f1ee 100644 --- a/package.nls.json +++ b/package.nls.json @@ -33,6 +33,7 @@ "python.command.python.datascience.runFileInteractive.title": "Run Current File in Python Interactive Window", "python.command.python.datascience.debugFileInteractive.title": "Debug Current File in Python Interactive Window", "python.command.python.datascience.runallcells.title": "Run All Cells", + "python.command.python.datascience.notebookeditor.runallcells.title": "Run All Notebook Cells", "python.command.python.datascience.runallcellsabove.title": "Run Above", "python.command.python.datascience.runcellandallbelow.title": "Run Below", "python.command.python.datascience.runallcellsabove.palette.title": "Run Cells Above Current Cell", @@ -60,6 +61,9 @@ "python.command.python.datascience.undocells.title": "Undo Last Python Interactive Action", "python.command.python.datascience.redocells.title": "Redo Last Python Interactive Action", "python.command.python.datascience.removeallcells.title": "Delete All Python Interactive Cells", + "python.command.python.datascience.notebookeditor.removeallcells.title": "Delete All Notebook Editor Cells", + "python.command.python.datascience.notebookeditor.runselectedcell.title": "Run Selected Notebook Cell", + "python.command.python.datascience.notebookeditor.addcellbelow.title": "Add Empty Cell to Notebook File", "python.command.python.datascience.interruptkernel.title": "Interrupt IPython Kernel", "python.command.python.datascience.restartkernel.title": "Restart IPython Kernel", "python.command.python.datascience.expandallcells.title": "Expand All Python Interactive Cells", @@ -217,7 +221,7 @@ "DataScience.exportingFormat": "Exporting {0}", "DataScience.exportCancel": "Cancel", "Common.canceled": "Canceled", - "DataScience.importChangeDirectoryComment": "#%% Change working directory from the workspace root to the ipynb file location. Turn this addition off with the DataScience.changeDirOnImportExport setting", + "DataScience.importChangeDirectoryComment": "{0} Change working directory from the workspace root to the ipynb file location. Turn this addition off with the DataScience.changeDirOnImportExport setting", "DataScience.exportChangeDirectoryComment": "# Change directory to VSCode workspace root so that relative path loads work correctly. Turn this addition off with the DataScience.changeDirOnImportExport setting", "DataScience.interruptKernelStatus": "Interrupting IPython Kernel", "DataScience.restartKernelAfterInterruptMessage": "Interrupting the kernel timed out. Do you want to restart the kernel instead? All variables will be lost.", @@ -339,7 +343,7 @@ "DataScience.jupyterDebuggerInstallPtvsdYes": "Yes", "DataScience.jupyterDebuggerInstallPtvsdNo": "No", "DataScience.cellStopOnErrorFormatMessage": "{0} cells were canceled due to an error in the previous cell.", - "DataScience.instructionComments": "# To add a new cell, type '#%%'\n# To add a new markdown cell, type '#%% [markdown]'\n", + "DataScience.instructionComments": "# To add a new cell, type '{0}'\n# To add a new markdown cell, type '{0} [markdown]'\n", "DataScience.scrollToCellTitleFormatMessage": "Go to [{0}]", "DataScience.remoteDebuggerNotSupported": "Debugging while attached to a remote server is not currently supported.", "DataScience.save": "Save notebook", diff --git a/package.nls.nl.json b/package.nls.nl.json index 8b4f57ee8a19..4197ddaa9e75 100644 --- a/package.nls.nl.json +++ b/package.nls.nl.json @@ -115,7 +115,7 @@ "DataScience.exportingFormat": "Aan het exporteren {0}", "DataScience.exportCancel": "Annuleren", "Common.canceled": "Geannuleerd", - "DataScience.importChangeDirectoryComment": "#%% De werkmap van de werkruimte root naar de ipynb-bestandslocatie veranderen. Schakel deze toevoeging uit met de instelling DataScience.changeDirOnImportExport", + "DataScience.importChangeDirectoryComment": "{0} De werkmap van de werkruimte root naar de ipynb-bestandslocatie veranderen. Schakel deze toevoeging uit met de instelling DataScience.changeDirOnImportExport", "DataScience.exportChangeDirectoryComment": "# De map wijzigen naar de VSCode-werktuimte root zodat de relatieve pad-ladingen correct werken. Schakel deze toevoeging uit met de instelling DataScience.changeDirOnImportExport", "DataScience.interruptKernelStatus": "IPython-kernel onderbreken", "DataScience.restartKernelAfterInterruptMessage": "Het onderbreken van de kernel duurde te lang. Wil je de kernel in plaats daarvan herstarten? Alle variabelen zullen verloren gaan.", diff --git a/pythonFiles/datascience/getJupyterVariableValue.py b/pythonFiles/datascience/getJupyterVariableValue.py index d483eb5cba89..a302cf6df673 100644 --- a/pythonFiles/datascience/getJupyterVariableValue.py +++ b/pythonFiles/datascience/getJupyterVariableValue.py @@ -84,9 +84,9 @@ def __call__(self, obj): return ''.join((x.encode('utf-8') if isinstance(x, unicode) else x) for x in self._repr(obj, 0)) else: return ''.join(self._repr(obj, 0)) - except Exception: + except Exception as e: try: - return 'An exception was raised: %r' % sys.exc_info()[1] + return 'An exception was raised: ' + str(e) except Exception: return 'An exception was raised' @@ -373,7 +373,7 @@ def _bytes_as_unicode_if_possible(self, obj_repr): # locale.getpreferredencoding() and 'utf-8). If no encoding can decode # the input, we return the original bytes. try_encodings = [] - encoding = self.sys_stdout_encoding or getattr(sys.stdout, 'encoding', '') + encoding = self.sys_stdout_encoding or getattr(VC_sys.stdout, 'encoding', '') if encoding: try_encodings.append(encoding.lower()) diff --git a/snippets/python.json b/snippets/python.json index 4d12d255699b..b1954d45cf17 100644 --- a/snippets/python.json +++ b/snippets/python.json @@ -242,12 +242,12 @@ }, "add/new/cell": { "prefix": "add/new/cell", - "body": "#%%", + "body": "# %%", "description": "Code snippet to add a new cell" }, "mark/markdown": { "prefix": "mark/markdown", - "body": "#%% [markdown]", + "body": "# %% [markdown]", "description": "Code snippet to add a new markdown cell" } } diff --git a/src/client/common/application/applicationShell.ts b/src/client/common/application/applicationShell.ts index 2ab4066f67b4..ea902b332fda 100644 --- a/src/client/common/application/applicationShell.ts +++ b/src/client/common/application/applicationShell.ts @@ -5,11 +5,14 @@ // tslint:disable:no-var-requires no-any unified-signatures import { injectable } from 'inversify'; -import { CancellationToken, Disposable, env, InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, ProgressOptions, QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, TreeView, TreeViewOptions, Uri, window, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode'; +import { CancellationToken, Disposable, env, Event, InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, ProgressOptions, QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, TreeView, TreeViewOptions, Uri, window, WindowState, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode'; import { IApplicationShell } from './types'; @injectable() export class ApplicationShell implements IApplicationShell { + public get onDidChangeWindowState(): Event { + return window.onDidChangeWindowState; + } public showInformationMessage(message: string, ...items: string[]): Thenable; public showInformationMessage(message: string, options: MessageOptions, ...items: string[]): Thenable; public showInformationMessage(message: string, ...items: T[]): Thenable; @@ -81,5 +84,4 @@ export class ApplicationShell implements IApplicationShell { public createOutputChannel(name: string): OutputChannel { return window.createOutputChannel(name); } - } diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index b452796c7ecc..607e7098febb 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -58,6 +58,9 @@ interface ICommandNameWithoutArgumentTypeMapping { [DSCommands.NotebookEditorRemoveAllCells]: []; [DSCommands.NotebookEditorInterruptKernel]: []; [DSCommands.NotebookEditorRestartKernel]: []; + [DSCommands.NotebookEditorRunAllCells]: []; + [DSCommands.NotebookEditorRunSelectedCell]: []; + [DSCommands.NotebookEditorAddCellBelow]: []; [DSCommands.ExpandAllCells]: []; [DSCommands.CollapseAllCells]: []; [DSCommands.ExportOutputAsNotebook]: []; diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index e3681214ed9a..a63f538e6a8e 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -49,6 +49,7 @@ import { TreeViewOptions, Uri, ViewColumn, + WindowState, WorkspaceConfiguration, WorkspaceEdit, WorkspaceFolder, @@ -64,6 +65,12 @@ import { ICommandNameArgumentTypeMapping } from './commands'; export const IApplicationShell = Symbol('IApplicationShell'); export interface IApplicationShell { + /** + * An [event](#Event) which fires when the focus state of the current window + * changes. The value of the event represents whether the window is focused. + */ + readonly onDidChangeWindowState: Event; + showInformationMessage(message: string, ...items: string[]): Thenable; /** diff --git a/src/client/common/application/webPanel.ts b/src/client/common/application/webPanel.ts index 2e3bc5900e01..bf650a4656bb 100644 --- a/src/client/common/application/webPanel.ts +++ b/src/client/common/application/webPanel.ts @@ -5,8 +5,7 @@ import '../../common/extensions'; import * as fs from 'fs-extra'; import * as path from 'path'; -import { Uri, ViewColumn, WebviewPanel, window } from 'vscode'; - +import { Uri, ViewColumn, Webview, WebviewPanel, window } from 'vscode'; import * as localize from '../../common/utils/localize'; import { Identifiers } from '../../datascience/constants'; import { IServiceContainer } from '../../ioc/types'; @@ -89,7 +88,7 @@ export class WebPanel implements IWebPanel { // Call our special function that sticks this script inside of an html page // and translates all of the paths to vscode-resource URIs - this.panel.webview.html = this.generateReactHtml(mainScriptPath, embeddedCss, settings); + this.panel.webview.html = this.generateReactHtml(mainScriptPath, this.panel.webview, embeddedCss, settings); // Reset when the current panel is closed this.disposableRegistry.push(this.panel.onDidDispose(() => { @@ -118,11 +117,9 @@ export class WebPanel implements IWebPanel { } // tslint:disable-next-line:no-any - private generateReactHtml(mainScriptPath: string, embeddedCss?: string, settings?: any) { - const uriBasePath = Uri.file(`${path.dirname(mainScriptPath)}/`); - const uriPath = Uri.file(mainScriptPath); - const uriBase = uriBasePath.with({ scheme: 'vscode-resource' }); - const uri = uriPath.with({ scheme: 'vscode-resource' }); + private generateReactHtml(mainScriptPath: string, webView: Webview, embeddedCss?: string, settings?: any) { + const uriBase = webView.asWebviewUri(Uri.file(`${path.dirname(mainScriptPath)}/`)); + const uri = webView.asWebviewUri(Uri.file(mainScriptPath)); const locDatabase = localize.getCollectionJSON(); const style = embeddedCss ? embeddedCss : ''; const settingsString = settings ? JSON.stringify(settings) : '{}'; diff --git a/src/client/common/cancellation.ts b/src/client/common/cancellation.ts index b325c2ff106a..fc30619df3fc 100644 --- a/src/client/common/cancellation.ts +++ b/src/client/common/cancellation.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; -import { CancellationToken } from 'vscode-jsonrpc'; +import { CancellationToken } from 'vscode'; import { createDeferred } from './utils/async'; import * as localize from './utils/localize'; @@ -10,20 +10,46 @@ import * as localize from './utils/localize'; * Error type thrown when canceling. */ export class CancellationError extends Error { - constructor() { super(localize.Common.canceled()); } } +/** + * Create a promise that will either resolve with a default value or reject when the token is cancelled. + * + * @export + * @template T + * @param {({ defaultValue: T; token: CancellationToken; cancelAction: 'reject' | 'resolve' })} options + * @returns {Promise} + */ +export function createPromiseFromCancellation(options: { defaultValue: T; token?: CancellationToken; cancelAction: 'reject' | 'resolve' }): Promise { + return new Promise((resolve, reject) => { + // Never resolve. + if (!options.token) { + return; + } + const complete = () => { + if (options.token!.isCancellationRequested) { + if (options.cancelAction === 'resolve') { + return resolve(options.defaultValue); + } + if (options.cancelAction === 'reject') { + return reject(new CancellationError()); + } + } + }; -export namespace Cancellation { + options.token.onCancellationRequested(complete); + }); +} +export namespace Cancellation { /** * Races a promise and cancellation. Promise can take a cancellation token too in order to listen to cancellation. * @param work function returning a promise to race * @param token token used for cancellation */ - export function race(work : (token?: CancellationToken) => Promise, token?: CancellationToken) : Promise { + export function race(work: (token?: CancellationToken) => Promise, token?: CancellationToken): Promise { if (token) { // Use a deferred promise. Resolves when the work finishes const deferred = createDeferred(); @@ -43,12 +69,12 @@ export namespace Cancellation { // Not canceled yet. When the work finishes // either resolve our promise or cancel. work(token) - .then((v) => { + .then(v => { if (!deferred.completed) { deferred.resolve(v); } }) - .catch((e) => { + .catch(e => { if (!deferred.completed) { deferred.reject(e); } @@ -66,7 +92,7 @@ export namespace Cancellation { * isCanceled returns a boolean indicating if the cancel token has been canceled. * @param cancelToken */ - export function isCanceled(cancelToken?: CancellationToken) : boolean { + export function isCanceled(cancelToken?: CancellationToken): boolean { return cancelToken ? cancelToken.isCancellationRequested : false; } @@ -74,10 +100,9 @@ export namespace Cancellation { * throws a CancellationError if the token is canceled. * @param cancelToken */ - export function throwIfCanceled(cancelToken?: CancellationToken) : void { + export function throwIfCanceled(cancelToken?: CancellationToken): void { if (isCanceled(cancelToken)) { throw new CancellationError(); } } - } diff --git a/src/client/common/process/pythonProcess.ts b/src/client/common/process/pythonProcess.ts index 6030df4a6e11..9d7d15333768 100644 --- a/src/client/common/process/pythonProcess.ts +++ b/src/client/common/process/pythonProcess.ts @@ -1,17 +1,26 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - import { injectable } from 'inversify'; import * as path from 'path'; + import { IServiceContainer } from '../../ioc/types'; import { EXTENSION_ROOT_DIR } from '../constants'; import { ErrorUtils } from '../errors/errorUtils'; import { ModuleNotInstalledError } from '../errors/moduleNotInstalledError'; import { traceError } from '../logger'; import { IFileSystem } from '../platform/types'; +import { waitForPromise } from '../utils/async'; import { Architecture } from '../utils/platform'; import { parsePythonVersion } from '../utils/version'; -import { ExecutionResult, InterpreterInfomation, IProcessService, IPythonExecutionService, ObservableExecutionResult, PythonVersionInfo, SpawnOptions } from './types'; +import { + ExecutionResult, + InterpreterInfomation, + IProcessService, + IPythonExecutionService, + ObservableExecutionResult, + PythonVersionInfo, + SpawnOptions +} from './types'; @injectable() export class PythonExecutionService implements IPythonExecutionService { @@ -28,8 +37,12 @@ export class PythonExecutionService implements IPythonExecutionService { public async getInterpreterInformation(): Promise { const file = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'interpreterInfo.py'); try { - const jsonValue = await this.procService.exec(this.pythonPath, [file], { mergeStdOutErr: true }) - .then(output => output.stdout.trim()); + // Sometimes the python path isn't valid, timeout if that's the case. + // See these two bugs: + // https://github.com/microsoft/vscode-python/issues/7569 + // https://github.com/microsoft/vscode-python/issues/7760 + const jsonValue = await waitForPromise(this.procService.exec(this.pythonPath, [file], { mergeStdOutErr: true }), 5000) + .then(output => output ? output.stdout.trim() : '--timed out--'); // --timed out-- should cause an exception let json: { versionInfo: PythonVersionInfo; sysPrefix: string; sysVersion: string; is64Bit: boolean }; try { diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 296b594d804b..cba278d6e533 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -355,6 +355,7 @@ export interface IDataScienceSettings { runMagicCommands?: string; runStartupCommands: string; debugJustMyCode: boolean; + defaultCellMarker?: string; } export const IConfigurationService = Symbol('IConfigurationService'); diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 883d86a28241..d3c1f802d5a6 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -148,7 +148,7 @@ export namespace DataScience { export const runAllCellsLensCommandTitle = localize('python.command.python.datascience.runallcells.title', 'Run all cells'); export const runAllCellsAboveLensCommandTitle = localize('python.command.python.datascience.runallcellsabove.title', 'Run above'); export const runCellAndAllBelowLensCommandTitle = localize('python.command.python.datascience.runcellandallbelow.title', 'Run Below'); - export const importChangeDirectoryComment = localize('DataScience.importChangeDirectoryComment', '#%% Change working directory from the workspace root to the ipynb file location. Turn this addition off with the DataScience.changeDirOnImportExport setting'); + export const importChangeDirectoryComment = localize('DataScience.importChangeDirectoryComment', '{0} Change working directory from the workspace root to the ipynb file location. Turn this addition off with the DataScience.changeDirOnImportExport setting'); export const exportChangeDirectoryComment = localize('DataScience.exportChangeDirectoryComment', '# Change directory to VSCode workspace root so that relative path loads work correctly. Turn this addition off with the DataScience.changeDirOnImportExport setting'); export const restartKernelMessage = localize('DataScience.restartKernelMessage', 'Do you want to restart the Jupter kernel? All variables will be lost.'); @@ -262,7 +262,7 @@ export namespace DataScience { export const jupyterDebuggerInstallPtvsdNo = localize('DataScience.jupyterDebuggerInstallPtvsdNo', 'No'); export const cellStopOnErrorFormatMessage = localize('DataScience.cellStopOnErrorFormatMessage', '{0} cells were canceled due to an error in the previous cell.'); export const scrollToCellTitleFormatMessage = localize('DataScience.scrollToCellTitleFormatMessage', 'Go to [{0}]'); - export const instructionComments = localize('DataScience.instructionComments', '# To add a new cell, type "#%%"\n# To add a new markdown cell, type "#%% [markdown]"\n'); + export const instructionComments = localize('DataScience.instructionComments', '# To add a new cell, type "{0}"\n# To add a new markdown cell, type "{0} [markdown]"\n'); export const invalidNotebookFileError = localize('DataScience.invalidNotebookFileError', 'Notebook is not in the correct format. Check the file for correct json.'); export const invalidNotebookFileErrorFormat = localize('DataScience.invalidNotebookFileError', '{0} is not a valid notebook file. Check the file for correct json.'); export const nativeEditorTitle = localize('DataScience.nativeEditorTitle', 'Notebook Editor'); diff --git a/src/client/datascience/cellFactory.ts b/src/client/datascience/cellFactory.ts index e6e8c1dfd2e2..6fcc20387ffb 100644 --- a/src/client/datascience/cellFactory.ts +++ b/src/client/datascience/cellFactory.ts @@ -43,8 +43,7 @@ function generateCodeCell(code: string[], file: string, line: number, id: string id: id, file: file, line: line, - state: CellState.init, - type: 'execute' + state: CellState.init }; } @@ -55,7 +54,6 @@ function generateMarkdownCell(code: string[], file: string, line: number, id: st file: file, line: line, state: CellState.finished, - type: 'execute', data: { cell_type: 'markdown', source: generateMarkdownFromCodeLines(code), diff --git a/src/client/datascience/common.ts b/src/client/datascience/common.ts index f031f924ce94..fb2b611032a2 100644 --- a/src/client/datascience/common.ts +++ b/src/client/datascience/common.ts @@ -7,7 +7,8 @@ import { noop } from '../../test/core'; const SingleQuoteMultiline = '\'\'\''; const DoubleQuoteMultiline = '\"\"\"'; -export function concatMultilineString(str: nbformat.MultilineString): string { + +function concatMultilineString(str: nbformat.MultilineString, trim: boolean): string { const nonLineFeedWhiteSpaceTrim = /(^[\t\f\v\r ]+|[\t\f\v\r ]+$)/g; // Local var so don't have to reset the lastIndex. if (Array.isArray(str)) { let result = ''; @@ -21,16 +22,35 @@ export function concatMultilineString(str: nbformat.MultilineString): string { } // Just trim whitespace. Leave \n in place - return result.replace(nonLineFeedWhiteSpaceTrim, ''); + return trim ? result.replace(nonLineFeedWhiteSpaceTrim, '') : result; } - return str.toString().replace(nonLineFeedWhiteSpaceTrim, ''); + return trim ? str.toString().replace(nonLineFeedWhiteSpaceTrim, '') : str.toString(); } -export function splitMultilineString(str: nbformat.MultilineString): string[] { - if (Array.isArray(str)) { - return str as string[]; +export function concatMultilineStringOutput(str: nbformat.MultilineString): string { + return concatMultilineString(str, true); +} +export function concatMultilineStringInput(str: nbformat.MultilineString): string { + return concatMultilineString(str, false); +} + +export function splitMultilineString(source: nbformat.MultilineString): string[] { + // Make sure a multiline string is back the way Jupyter expects it + if (Array.isArray(source)) { + return source as string[]; + } + const str = source.toString(); + if (str.length > 0) { + // Each line should be a separate entry, but end with a \n if not last entry + const arr = str.split('\n'); + return arr.map((s, i) => { + if (i < arr.length - 1) { + return `${s}\n`; + } + return s; + }).filter(s => s.length > 0); // Skip last one if empty (it's the only one that could be length 0) } - return str.toString().split('\n'); + return []; } // Strip out comment lines from code diff --git a/src/client/datascience/constants.ts b/src/client/datascience/constants.ts index 3e8c5f24ce40..6bc7f42a4772 100644 --- a/src/client/datascience/constants.ts +++ b/src/client/datascience/constants.ts @@ -35,6 +35,9 @@ export namespace Commands { export const NotebookEditorRemoveAllCells = 'python.datascience.notebookeditor.removeallcells'; export const NotebookEditorInterruptKernel = 'python.datascience.notebookeditor.interruptkernel'; export const NotebookEditorRestartKernel = 'python.datascience.notebookeditor.restartkernel'; + export const NotebookEditorRunAllCells = 'python.datascience.notebookeditor.runallcells'; + export const NotebookEditorRunSelectedCell = 'python.datascience.notebookeditor.runselectedcell'; + export const NotebookEditorAddCellBelow = 'python.datascience.notebookeditor.addcellbelow'; export const ExpandAllCells = 'python.datascience.expandallcells'; export const CollapseAllCells = 'python.datascience.collapseallcells'; export const ExportOutputAsNotebook = 'python.datascience.exportoutputasnotebook'; @@ -71,6 +74,7 @@ export namespace EditorContexts { export const HaveNativeCells = 'python.datascience.havenativecells'; export const HaveNativeRedoableCells = 'python.datascience.havenativeredoablecells'; export const HaveNative = 'python.datascience.havenative'; + export const HaveCellSelected = 'python.datascience.havecellselected'; } export namespace RegExpValues { @@ -277,12 +281,13 @@ export namespace Identifiers { export const SvgSizeTag = 'sizeTag={{0}, {1}}'; export const InteractiveWindowIdentity = 'history://EC155B3B-DC18-49DC-9E99-9A948AA2F27B'; export const InteractiveWindowIdentityScheme = 'history'; + export const DefaultCodeCellMarker = '# %%'; } export namespace CodeSnippits { export const ChangeDirectory = ['{0}', '{1}', 'import os', 'try:', '\tos.chdir(os.path.join(os.getcwd(), \'{2}\'))', '\tprint(os.getcwd())', 'except:', '\tpass', '']; export const ChangeDirectoryCommentIdentifier = '# ms-python.python added'; // Not translated so can compare. - export const ImportIPython = '#%%\nfrom IPython import get_ipython\n\n'; + export const ImportIPython = '{0}\nfrom IPython import get_ipython\n\n{1}'; export const MatplotLibInitSvg = `import matplotlib\n%matplotlib inline\n${Identifiers.MatplotLibDefaultParams} = dict(matplotlib.rcParams)\n%config InlineBackend.figure_formats = 'svg', 'png'`; export const MatplotLibInitPng = `import matplotlib\n%matplotlib inline\n${Identifiers.MatplotLibDefaultParams} = dict(matplotlib.rcParams)\n%config InlineBackend.figure_formats = 'png'`; } diff --git a/src/client/datascience/editor-integration/codewatcher.ts b/src/client/datascience/editor-integration/codewatcher.ts index 6ebdb12a6423..fab1715b11bd 100644 --- a/src/client/datascience/editor-integration/codewatcher.ts +++ b/src/client/datascience/editor-integration/codewatcher.ts @@ -22,7 +22,7 @@ import { StopWatch } from '../../common/utils/stopWatch'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { ICodeExecutionHelper } from '../../terminals/types'; import { CellMatcher } from '../cellMatcher'; -import { Commands, Telemetry } from '../constants'; +import { Commands, Identifiers, Telemetry } from '../constants'; import { ICodeLensFactory, ICodeWatcher, IDataScienceErrorHandler, IInteractiveWindowProvider } from '../types'; @injectable() @@ -268,9 +268,10 @@ export class CodeWatcher implements ICodeWatcher { public async addEmptyCellToBottom(): Promise { const editor = this.documentManager.activeTextEditor; + const cellDelineator = this.defaultCellMarker; if (editor) { editor.edit((editBuilder) => { - editBuilder.insert(new Position(editor.document.lineCount, 0), '\n\n#%%\n'); + editBuilder.insert(new Position(editor.document.lineCount, 0), `\n\n${cellDelineator}\n`); }); const newPosition = new Position(editor.document.lineCount + 3, 0); // +3 to account for the added spaces and to position after the new mark @@ -286,6 +287,7 @@ export class CodeWatcher implements ICodeWatcher { const editor = this.documentManager.activeTextEditor; const cellMatcher = new CellMatcher(); let index = 0; + const cellDelineator = this.defaultCellMarker; if (editor) { editor.edit((editBuilder) => { @@ -295,14 +297,14 @@ export class CodeWatcher implements ICodeWatcher { if (cellMatcher.isCell(editor.document.lineAt(i).text)) { lastCell = false; index = i; - editBuilder.insert(new Position(i, 0), '#%%\n\n'); + editBuilder.insert(new Position(i, 0), `${cellDelineator}\n\n`); break; } } if (lastCell) { index = editor.document.lineCount; - editBuilder.insert(new Position(editor.document.lineCount, 0), '\n#%%\n'); + editBuilder.insert(new Position(editor.document.lineCount, 0), `\n${cellDelineator}\n`); } }); } @@ -313,6 +315,10 @@ export class CodeWatcher implements ICodeWatcher { .then(() => this.advanceToRange(new Range(newPosition, newPosition))); } + private get defaultCellMarker(): string { + return this.configService.getSettings().datascience.defaultCellMarker || Identifiers.DefaultCodeCellMarker; + } + private onCodeLensFactoryUpdated(): void { // Update our code lenses. if (this.document) { @@ -333,7 +339,7 @@ export class CodeWatcher implements ICodeWatcher { } this.sendPerceivedCellExecute(stopWatch); } catch (err) { - this.dataScienceErrorHandler.handleError(err).ignoreErrors(); + await this.dataScienceErrorHandler.handleError(err); } return result; @@ -347,7 +353,7 @@ export class CodeWatcher implements ICodeWatcher { const activeInteractiveWindow = await this.interactiveWindowProvider.getOrCreateActive(); return activeInteractiveWindow.addMessage(message); } catch (err) { - this.dataScienceErrorHandler.handleError(err).ignoreErrors(); + await this.dataScienceErrorHandler.handleError(err); } } } @@ -419,7 +425,7 @@ export class CodeWatcher implements ICodeWatcher { if (editor) { editor.edit((editBuilder) => { - editBuilder.insert(new Position(currentRange.end.line + 1, 0), '\n\n#%%\n'); + editBuilder.insert(new Position(currentRange.end.line + 1, 0), `\n\n${this.defaultCellMarker}\n`); }); } diff --git a/src/client/datascience/errorHandler/errorHandler.ts b/src/client/datascience/errorHandler/errorHandler.ts index 290af382c0b7..049d981ea6f4 100644 --- a/src/client/datascience/errorHandler/errorHandler.ts +++ b/src/client/datascience/errorHandler/errorHandler.ts @@ -20,34 +20,30 @@ export class DataScienceErrorHandler implements IDataScienceErrorHandler { public async handleError(err: Error): Promise { if (err instanceof JupyterInstallError) { - this.applicationShell.showInformationMessage( + const response = await this.applicationShell.showInformationMessage( err.message, localize.DataScience.jupyterInstall(), localize.DataScience.notebookCheckForImportNo(), - err.actionTitle) - .then(response => { - if (response === localize.DataScience.jupyterInstall()) { - return this.channels.getInstallationChannels() - .then(installers => { - if (installers) { - // If Conda is available, always pick it as the user must have a Conda Environment - const installer = installers.find(ins => ins.name === 'Conda'); - const product = ProductNames.get(Product.jupyter); + err.actionTitle); + if (response === localize.DataScience.jupyterInstall()) { + const installers = await this.channels.getInstallationChannels(); + if (installers) { + // If Conda is available, always pick it as the user must have a Conda Environment + const installer = installers.find(ins => ins.name === 'Conda'); + const product = ProductNames.get(Product.jupyter); - if (installer && product) { - installer.installModule(product) - .catch(e => this.applicationShell.showErrorMessage(e.message, localize.DataScience.pythonInteractiveHelpLink())); - } else if (installers[0] && product) { - installers[0].installModule(product) - .catch(e => this.applicationShell.showErrorMessage(e.message, localize.DataScience.pythonInteractiveHelpLink())); - } - } - }); - } else if (response === err.actionTitle) { - // This is a special error that shows a link to open for more help - this.applicationShell.openUrl(err.action); + if (installer && product) { + installer.installModule(product) + .catch(e => this.applicationShell.showErrorMessage(e.message, localize.DataScience.pythonInteractiveHelpLink())); + } else if (installers[0] && product) { + installers[0].installModule(product) + .catch(e => this.applicationShell.showErrorMessage(e.message, localize.DataScience.pythonInteractiveHelpLink())); } - }); + } + } else if (response === err.actionTitle) { + // This is a special error that shows a link to open for more help + this.applicationShell.openUrl(err.action); + } } else if (err instanceof JupyterSelfCertsError) { // Don't show the message for self cert errors noop(); diff --git a/src/client/datascience/gather/gather.ts b/src/client/datascience/gather/gather.ts index c57b3d303904..8a578989a719 100644 --- a/src/client/datascience/gather/gather.ts +++ b/src/client/datascience/gather/gather.ts @@ -14,7 +14,8 @@ import * as localize from '../../common/utils/localize'; import { Common } from '../../common/utils/localize'; import { noop } from '../../common/utils/misc'; import { CellMatcher } from '../cellMatcher'; -import { concatMultilineString } from '../common'; +import { concatMultilineStringInput } from '../common'; +import { Identifiers } from '../constants'; import { CellState, ICell as IVscCell, IGatherExecution, INotebookExecutionLogger } from '../types'; /** @@ -59,7 +60,7 @@ export class GatherExecution implements IGatherExecution, INotebookExecutionLogg // Strip first line marker. We can't do this at JupyterServer.executeCodeObservable because it messes up hashing const cellMatcher = new CellMatcher(this.configService.getSettings().datascience); - cloneCell.data.source = cellMatcher.stripFirstMarker(concatMultilineString(vscCell.data.source)); + cloneCell.data.source = cellMatcher.stripFirstMarker(concatMultilineStringInput(vscCell.data.source)); // Convert IVscCell to IGatherCell const cell = convertVscToGatherCell(cloneCell) as LogCell; @@ -79,9 +80,13 @@ export class GatherExecution implements IGatherExecution, INotebookExecutionLogg if (cell === undefined) { return ''; } + + // Get the default cell marker as we need to replace #%% with it. + const defaultCellMarker = this.configService.getSettings().datascience.defaultCellMarker || Identifiers.DefaultCodeCellMarker; + // Call internal slice method const slices = this._executionSlicer.sliceAllExecutions(cell); - const program = slices[0].cellSlices.reduce(concat, ''); + const program = slices.length > 0 ? slices[0].cellSlices.reduce(concat, '').replace(/#%%/g, defaultCellMarker) : ''; // Add a comment at the top of the file explaining what gather does const descriptor = '# This file contains the minimal amount of code required to produce the code cell you gathered.\n'; diff --git a/src/client/datascience/interactive-common/intellisense/baseIntellisenseProvider.ts b/src/client/datascience/interactive-common/intellisense/baseIntellisenseProvider.ts index cd79d8b090bd..f052762345b6 100644 --- a/src/client/datascience/interactive-common/intellisense/baseIntellisenseProvider.ts +++ b/src/client/datascience/interactive-common/intellisense/baseIntellisenseProvider.ts @@ -22,19 +22,14 @@ import { CancellationError } from '../../../common/cancellation'; import { traceWarning } from '../../../common/logger'; import { IFileSystem, TemporaryFile } from '../../../common/platform/types'; import { createDeferred, Deferred, waitForPromise } from '../../../common/utils/async'; -import { concatMultilineString } from '../../common'; +import { concatMultilineStringInput } from '../../common'; import { Identifiers, Settings } from '../../constants'; -import { - IInteractiveWindowInfo, - IInteractiveWindowListener, - IInteractiveWindowProvider, - IJupyterExecution, - INotebook -} from '../../types'; +import { IInteractiveWindowListener, IInteractiveWindowProvider, IJupyterExecution, INotebook } from '../../types'; import { IAddCell, ICancelIntellisenseRequest, IEditCell, + IInsertCell, IInteractiveWindowMapping, ILoadAllCells, INotebookIdentity, @@ -42,7 +37,8 @@ import { IProvideCompletionItemsRequest, IProvideHoverRequest, IProvideSignatureHelpRequest, - IRemoveCell + IRemoveCell, + ISwapCells } from '../interactiveWindowTypes'; import { convertStringsToSuggestions } from './conversion'; import { IntellisenseDocument } from './intellisenseDocument'; @@ -110,10 +106,18 @@ export abstract class BaseIntellisenseProvider implements IInteractiveWindowList this.dispatchMessage(message, payload, this.addCell); break; + case InteractiveWindowMessages.InsertCell: + this.dispatchMessage(message, payload, this.insertCell); + break; + case InteractiveWindowMessages.RemoveCell: this.dispatchMessage(message, payload, this.removeCell); break; + case InteractiveWindowMessages.SwapCells: + this.dispatchMessage(message, payload, this.swapCells); + break; + case InteractiveWindowMessages.DeleteAllCells: this.dispatchMessage(message, payload, this.removeAllCells); break; @@ -130,10 +134,6 @@ export abstract class BaseIntellisenseProvider implements IInteractiveWindowList this.dispatchMessage(message, payload, this.loadAllCells); break; - case InteractiveWindowMessages.SendInfo: - this.dispatchMessage(message, payload, this.handleNativeEditorChanges); - break; - default: break; } @@ -337,6 +337,15 @@ export abstract class BaseIntellisenseProvider implements IInteractiveWindowList } } + private async insertCell(request: IInsertCell): Promise { + // Get the document and then pass onto the sub class + const document = await this.getDocument(); + if (document) { + const changes = document.insertCell(request.id, request.code, request.codeCellAbove); + return this.handleChanges(undefined, document, changes); + } + } + private async editCell(request: IEditCell): Promise { // First get the document const document = await this.getDocument(); @@ -346,47 +355,45 @@ export abstract class BaseIntellisenseProvider implements IInteractiveWindowList } } - private removeCell(_request: IRemoveCell): Promise { - // Skip this request. The logic here being that - // a user can remove a cell from the UI, but it's still loaded into the Jupyter kernel. - return Promise.resolve(); + private async removeCell(request: IRemoveCell): Promise { + // First get the document + const document = await this.getDocument(); + if (document) { + const changes = document.remove(request.id); + return this.handleChanges(undefined, document, changes); + } } - private removeAllCells(): Promise { - // Skip this request. The logic here being that - // a user can remove a cell from the UI, but it's still loaded into the Jupyter kernel. - return Promise.resolve(); + private async swapCells(request: ISwapCells): Promise { + // First get the document + const document = await this.getDocument(); + if (document) { + const changes = document.swap(request.firstCellId, request.secondCellId); + return this.handleChanges(undefined, document, changes); + } } - private async loadAllCells(payload: ILoadAllCells) { + private async removeAllCells(): Promise { + // First get the document const document = await this.getDocument(); if (document) { - document.switchToEditMode(); - await Promise.all(payload.cells.map(async cell => { - if (cell.data.cell_type === 'code') { - const text = concatMultilineString(cell.data.source); - const addCell: IAddCell = { - fullText: text, - currentText: text, - file: cell.file, - id: cell.id - }; - await this.addCell(addCell); - } - })); + const changes = document.removeAll(); + return this.handleChanges(undefined, document, changes); } } - private async handleNativeEditorChanges(payload: IInteractiveWindowInfo) { + private async loadAllCells(payload: ILoadAllCells) { const document = await this.getDocument(); - let changes: TextDocumentContentChangeEvent[][] = []; - const file = payload.visibleCells[0] ? payload.visibleCells[0].file : undefined; - if (document) { - changes = document.handleNativeEditorCellChanges(payload.visibleCells); - } + const changes = document.loadAllCells(payload.cells.filter(c => c.data.cell_type === 'code').map(cell => { + return { + code: concatMultilineStringInput(cell.data.source), + id: cell.id + }; + })); - await Promise.all(changes.map(c => this.handleChanges(file, document, c))); + await this.handleChanges(Identifiers.EmptyFileName, document, changes); + } } private async restartKernel(): Promise { diff --git a/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts b/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts index 14ac6495a19a..fc07f5a37cff 100644 --- a/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts +++ b/src/client/datascience/interactive-common/intellisense/intellisenseDocument.ts @@ -8,9 +8,7 @@ import { EndOfLine, Position, Range, TextDocument, TextDocumentContentChangeEven import * as vscodeLanguageClient from 'vscode-languageclient'; import { PYTHON_LANGUAGE } from '../../../common/constants'; -import { concatMultilineString } from '../../common'; import { Identifiers } from '../../constants'; -import { ICell } from '../../types'; import { DefaultWordPattern, ensureValidWordDefinition, getWordAtText, regExpLeadsToEndlessLoop } from './wordHelper'; class IntellisenseLine implements TextLine { @@ -118,10 +116,6 @@ export class IntellisenseDocument implements TextDocument { return this._lines.length; } - public switchToEditMode() { - this.inEditMode = true; - } - public lineAt(position: Position | number): TextLine { if (typeof position === 'number') { return this._lines[position as number]; @@ -198,46 +192,56 @@ export class IntellisenseDocument implements TextDocument { }; } - public handleNativeEditorCellChanges(cells: ICell[]): TextDocumentContentChangeEvent[][] { - const changes: TextDocumentContentChangeEvent[][] = []; - - if (this.inEditMode) { - const incomingCells = cells.filter(c => c.data.cell_type === 'code'); - const currentCellCount = this._cellRanges.length - 1; - - if (currentCellCount < incomingCells.length) { // Cell was added - incomingCells.forEach((cell, i) => { - if (!this.hasCell(cell.id)) { - const text = concatMultilineString(cell.data.source); - - // addCell to the end of the document, or if adding in the middle, - // send the id of the next cell to get its offset in the document - if (i + 1 > incomingCells.length - 1) { - changes.push(this.addCell(text, text, cell.id)); - } else { - changes.push(this.addCell(text, text, cell.id, incomingCells[i + 1].id)); - } - } - }); - } else if (currentCellCount > incomingCells.length) { // Cell was deleted - const change = this.lookForCellToDelete(incomingCells); - - if (change.length > 0) { - changes.push(change); - } - } else { // Cell might have moved - const change = this.lookForCellMovement(incomingCells); + public loadAllCells(cells: { code: string; id: string }[]): TextDocumentContentChangeEvent[] { + let changes: TextDocumentContentChangeEvent[] = []; + if (!this.inEditMode) { + this.inEditMode = true; + this._version += 1; - if (change.length > 0) { - changes.push(change); + // Normalize all of the cells, removing \r and separating each + // with a newline + const normalized = cells.map(c => { + return { + id: c.id, + code: `${c.code.replace(/\r/g, '')}\n` + }; + }); + + // Contents are easy, just load all of the code in a row + this._contents = normalized.map(c => c.code).reduce((p, c) => { + return `${p}${c}`; + }); + + // Cell ranges are slightly more complicated + let prev: number = 0; + this._cellRanges = normalized.map(c => { + const result = { + id: c.id, + start: prev, + fullEnd: prev + c.code.length, + currentEnd: prev + c.code.length + }; + prev += c.code.length; + return result; + }); + + // Then create the lines. + this._lines = this.createLines(); + + // Return our changes + changes = [ + { + range: this.createSerializableRange(new Position(0, 0), new Position(0, 0)), + rangeOffset: 0, + rangeLength: 0, // Adds are always zero + text: this._contents } - } + ]; } - return changes; } - public addCell(fullCode: string, currentCode: string, id: string, cellId?: string): TextDocumentContentChangeEvent[] { + public addCell(fullCode: string, currentCode: string, id: string): TextDocumentContentChangeEvent[] { // This should only happen once for each cell. this._version += 1; @@ -251,51 +255,66 @@ export class IntellisenseDocument implements TextDocument { const newCode = `${normalized}\n`; const newCurrentCode = `${normalizedCurrent}\n`; - // We should start just before the last cell for the interactive window - // But return the start of the next cell for the native editor, - // in case we add a cell at the end in the native editor, - // just don't send a cellId to get an offset at the end of the document - const fromOffset = this.getEditCellOffset(cellId); + // We should start just before the last cell. + const fromOffset = this.getEditCellOffset(); // Split our text between the edit text and the cells above const before = this._contents.substr(0, fromOffset); const after = this._contents.substr(fromOffset); const fromPosition = this.positionAt(fromOffset); - // for the interactive window or if the cell was added last, - // add cell to the end - let splicePosition = this._cellRanges.length - 1; + // Save the range for this cell () + this._cellRanges.splice(this._cellRanges.length - 1, 0, + { id, start: fromOffset, fullEnd: fromOffset + newCode.length, currentEnd: fromOffset + newCurrentCode.length }); - // for the native editor, find the index to add the cell to - if (cellId) { - const index = this._cellRanges.findIndex(c => c.id === cellId); + // Update our entire contents and recompute our lines + this._contents = `${before}${newCode}${after}`; + this._lines = this.createLines(); + this._cellRanges[this._cellRanges.length - 1].start += newCode.length; + this._cellRanges[this._cellRanges.length - 1].fullEnd += newCode.length; + this._cellRanges[this._cellRanges.length - 1].currentEnd += newCode.length; - if (index > -1) { - splicePosition = index; + return [ + { + range: this.createSerializableRange(fromPosition, fromPosition), + rangeOffset: fromOffset, + rangeLength: 0, // Adds are always zero + text: newCode } - } + ]; + } - // Save the range for this cell () - this._cellRanges.splice(splicePosition, 0, - { id, start: fromOffset, fullEnd: fromOffset + newCode.length, currentEnd: fromOffset + newCurrentCode.length }); + public insertCell(id: string, code: string, codeCellAbove: string | undefined): TextDocumentContentChangeEvent[] { + // This should only happen once for each cell. + this._version += 1; + + // Make sure to put a newline between this code and the next code + const newCode = `${code.replace(/\r/g, '')}\n`; + + // Figure where this goes + const aboveIndex = this._cellRanges.findIndex(r => r.id === codeCellAbove); + const insertIndex = aboveIndex + 1; + + // Compute where we start from. + const fromOffset = insertIndex < this._cellRanges.length ? this._cellRanges[insertIndex].start : this._contents.length; + + // Split our text between the text and the cells above + const before = this._contents.substr(0, fromOffset); + const after = this._contents.substr(fromOffset); + const fromPosition = this.positionAt(fromOffset); // Update our entire contents and recompute our lines this._contents = `${before}${newCode}${after}`; this._lines = this.createLines(); - if (cellId) { - // With the native editor, we fix all the positions that changed after adding - for (let i = splicePosition + 1; i < this._cellRanges.length; i += 1) { - this._cellRanges[i].start += newCode.length; - this._cellRanges[i].fullEnd += newCode.length; - this._cellRanges[i].currentEnd += newCode.length; - } - } else { - // with the interactive window, we just fix the positon of the last cell - this._cellRanges[this._cellRanges.length - 1].start += newCode.length; - this._cellRanges[this._cellRanges.length - 1].fullEnd += newCode.length; - this._cellRanges[this._cellRanges.length - 1].currentEnd += newCode.length; + // Move all the other cell ranges down + for (let i = insertIndex; i <= this._cellRanges.length - 1; i += 1) { + this._cellRanges[i].start += newCode.length; + this._cellRanges[i].fullEnd += newCode.length; + this._cellRanges[i].currentEnd += newCode.length; } + this._cellRanges.splice(insertIndex, 0, + { id, start: fromOffset, fullEnd: fromOffset + newCode.length, currentEnd: fromOffset + newCode.length }); return [ { @@ -308,12 +327,12 @@ export class IntellisenseDocument implements TextDocument { } public removeAllCells(): TextDocumentContentChangeEvent[] { - // Remove everything up to the edit cell - if (this._cellRanges.length > 1) { + // Remove everything + if (this.inEditMode) { this._version += 1; // Compute the offset for the edit cell - const toOffset = this._cellRanges[this._cellRanges.length - 1].start; + const toOffset = this._cellRanges[this._cellRanges.length - 1].fullEnd; const from = this.positionAt(0); const to = this.positionAt(toOffset); @@ -321,12 +340,7 @@ export class IntellisenseDocument implements TextDocument { const result = this.removeRange('', from, to, 0); // Update our cell range - this._cellRanges = [{ - id: Identifiers.EditCellId, - start: 0, - fullEnd: this._cellRanges[this._cellRanges.length - 1].fullEnd - toOffset, - currentEnd: this._cellRanges[this._cellRanges.length - 1].fullEnd - toOffset - }]; + this._cellRanges = []; return result; } @@ -362,6 +376,123 @@ export class IntellisenseDocument implements TextDocument { return []; } + public remove(id: string): TextDocumentContentChangeEvent[] { + let change: TextDocumentContentChangeEvent[] = []; + + const index = this._cellRanges.findIndex(c => c.id === id); + // Ignore unless in edit mode. For non edit mode, cells are still there. + if (index >= 0 && this.inEditMode) { + this._version += 1; + + const found = this._cellRanges[index]; + const foundLength = found.currentEnd - found.start; + const from = new Position(this.getLineFromOffset(found.start), 0); + const to = this.positionAt(found.currentEnd); + + // Remove from the cell ranges. + for (let i = index + 1; i <= this._cellRanges.length - 1; i += 1) { + this._cellRanges[i].start -= foundLength; + this._cellRanges[i].fullEnd -= foundLength; + this._cellRanges[i].currentEnd -= foundLength; + } + this._cellRanges.splice(index, 1); + + // Recreate the contents + const before = this._contents.substr(0, found.start); + const after = this._contents.substr(found.currentEnd); + this._contents = `${before}${after}`; + this._lines = this.createLines(); + + change = [ + { + range: this.createSerializableRange(from, to), + rangeOffset: found.start, + rangeLength: foundLength, + text: '' + } + ]; + } + + return change; + } + + public swap(first: string, second: string): TextDocumentContentChangeEvent[] { + let change: TextDocumentContentChangeEvent[] = []; + + const firstIndex = this._cellRanges.findIndex(c => c.id === first); + const secondIndex = this._cellRanges.findIndex(c => c.id === second); + if (firstIndex >= 0 && secondIndex >= 0 && firstIndex !== secondIndex && this.inEditMode) { + this._version += 1; + + const topIndex = firstIndex < secondIndex ? firstIndex : secondIndex; + const bottomIndex = firstIndex > secondIndex ? firstIndex : secondIndex; + const top = { ...this._cellRanges[topIndex] }; + const bottom = { ...this._cellRanges[bottomIndex] }; + + const from = new Position(this.getLineFromOffset(top.start), 0); + const to = this.positionAt(bottom.currentEnd); + + // Swap everything + this._cellRanges[topIndex].id = bottom.id; + this._cellRanges[topIndex].fullEnd = top.start + (bottom.fullEnd - bottom.start); + this._cellRanges[topIndex].currentEnd = top.start + (bottom.currentEnd - bottom.start); + this._cellRanges[bottomIndex].id = top.id; + this._cellRanges[bottomIndex].start = this._cellRanges[topIndex].fullEnd; + this._cellRanges[bottomIndex].fullEnd = this._cellRanges[topIndex].fullEnd + (top.fullEnd - top.start); + this._cellRanges[bottomIndex].currentEnd = this._cellRanges[topIndex].fullEnd + (top.currentEnd - top.start); + + const fromOffset = this.convertToOffset(from); + const toOffset = this.convertToOffset(to); + + // Recreate our contents, and then recompute all of our lines + const before = this._contents.substr(0, fromOffset); + const topText = this._contents.substr(top.start, top.fullEnd - top.start); + const bottomText = this._contents.substr(bottom.start, bottom.fullEnd - bottom.start); + const after = this._contents.substr(toOffset); + const replacement = `${bottomText}${topText}`; + this._contents = `${before}${replacement}${after}`; + this._lines = this.createLines(); + + // Change is a full replacement + change = [ + { + range: this.createSerializableRange(from, to), + rangeOffset: fromOffset, + rangeLength: toOffset - fromOffset, + text: replacement + } + ]; + } + + return change; + } + + public removeAll(): TextDocumentContentChangeEvent[] { + let change: TextDocumentContentChangeEvent[] = []; + // Ignore unless in edit mode. + if (this._lines.length > 0 && this.inEditMode) { + this._version += 1; + + const from = this._lines[0].range.start; + const to = this._lines[this._lines.length - 1].rangeIncludingLineBreak.end; + const length = this._contents.length; + this._cellRanges = []; + this._contents = ''; + this._lines = []; + + change = [ + { + range: this.createSerializableRange(from, to), + rangeOffset: 0, + rangeLength: length, + text: '' + } + ]; + } + + return change; + } + public convertToDocumentPosition(id: string, line: number, ch: number): Position { // Monaco is 1 based, and we need to add in our cell offset. const cellIndex = this._cellRanges.findIndex(c => c.id === id); @@ -405,11 +536,6 @@ export class IntellisenseDocument implements TextDocument { return this._cellRanges[this._cellRanges.length - 1].start; } - private hasCell(cellId: string) { - const foundIt = this._cellRanges.find(c => c.id === cellId); - return foundIt ? true : false; - } - private getLineFromOffset(offset: number) { let lineCounter = 0; @@ -422,83 +548,6 @@ export class IntellisenseDocument implements TextDocument { return lineCounter; } - private lookForCellToDelete(incomingCells: ICell[]): TextDocumentContentChangeEvent[] { - let change: TextDocumentContentChangeEvent[] = []; - - this._cellRanges.forEach((cell, i) => { - const foundIt = incomingCells.find(c => c.id === cell.id); - - // if cell is not found in the document and its not the last edit cell, we remove it - if (!foundIt && i !== this._cellRanges.length - 1) { - const from = new Position(this.getLineFromOffset(cell.start), 0); - const to = this.positionAt(cell.currentEnd); - - // for some reason, start for the next cell isn't updated on removeRange, - // so we update it here - this._cellRanges[i + 1].start = cell.start; - this._cellRanges.splice(i, 1); - change = this.removeRange('', from, to, i); - } - }); - - return change; - } - - private lookForCellMovement(incomingCells: ICell[]): TextDocumentContentChangeEvent[] { - for (let i = 0; i < incomingCells.length && this._cellRanges.length > 1; i += 1) { - - if (incomingCells[i].id !== this._cellRanges[i].id) { - const lineBreak = '\n'; - const text = this._contents.substr(this._cellRanges[i].start, this._cellRanges[i].currentEnd - this._cellRanges[i].start - 1); - const newText = concatMultilineString(incomingCells[i].data.source) + lineBreak + text + lineBreak; - - // swap contents - this._contents = this._contents.substring(0, this._cellRanges[i].start) - + this._contents.substring(this._cellRanges[i + 1].start, this._cellRanges[i + 1].fullEnd) - + this._contents.substring(this._cellRanges[i].start, this._cellRanges[i].fullEnd) - + this._contents.substring(this._cellRanges[i + 1].fullEnd); - - // create lines - this._lines = this.createLines(); - - // swap cell ranges - const temp1Id = this._cellRanges[i].id; - const temp1Start = this._cellRanges[i].start; - const temp1End = this._cellRanges[i].fullEnd; - const temp1Length = temp1End - temp1Start; - - const temp2Id = this._cellRanges[i + 1].id; - const temp2Start = this._cellRanges[i + 1].start; - const temp2End = this._cellRanges[i + 1].fullEnd; - const temp2Length = temp2End - temp2Start; - - this._cellRanges[i].id = temp2Id; - this._cellRanges[i].start = temp1Start; - this._cellRanges[i].currentEnd = temp1Start + temp2Length; - this._cellRanges[i].fullEnd = temp1Start + temp2Length; - - this._cellRanges[i + 1].id = temp1Id; - this._cellRanges[i + 1].start = temp1Start + temp2Length; - this._cellRanges[i + 1].currentEnd = temp1Start + temp2Length + temp1Length; - this._cellRanges[i + 1].fullEnd = temp1Start + temp2Length + temp1Length; - - const from = new Position(this.getLineFromOffset(temp1Start), 0); - const to = new Position(this.getLineFromOffset(temp2End - 1), temp2End - temp2Start); - const fromOffset = temp1Start; - const toOffset = temp2End; - - return [{ - range: this.createSerializableRange(from, to), - rangeOffset: fromOffset, - rangeLength: toOffset - fromOffset, - text: newText - }]; - } - } - - return []; - } - private removeRange(newText: string, from: Position, to: Position, cellIndex: number): TextDocumentContentChangeEvent[] { const fromOffset = this.convertToOffset(from); const toOffset = this.convertToOffset(to); @@ -552,6 +601,9 @@ export class IntellisenseDocument implements TextDocument { } private createSerializableRange(start: Position, end: Position): Range { + // This funciton is necessary so that the Range can be passed back + // over a remote connection without including all of the extra fields that + // VS code puts into a Range object. const result = { start: { line: start.line, diff --git a/src/client/datascience/interactive-common/interactiveBase.ts b/src/client/datascience/interactive-common/interactiveBase.ts index ebd865a2f22b..247f7d1938c8 100644 --- a/src/client/datascience/interactive-common/interactiveBase.ts +++ b/src/client/datascience/interactive-common/interactiveBase.ts @@ -102,7 +102,7 @@ export abstract class InteractiveBase extends WebViewHost 0; const line = editor.selection.start.line; const revealLine = line + 1; + const defaultCellMarker = this.configService.getSettings().datascience.defaultCellMarker || Identifiers.DefaultCodeCellMarker; let newCode = `${source}${os.EOL}`; if (hasCellsAlready) { // See if inside of a range or not. @@ -1005,13 +1004,13 @@ export abstract class InteractiveBase extends WebViewHost { @@ -1139,27 +1138,30 @@ export abstract class InteractiveBase extends WebViewHost { let activeInterpreter: PythonInterpreter | undefined; try { - activeInterpreter = await this.interpreterService.getActiveInterpreter(); - const usableInterpreter = await this.jupyterExecution.getUsableJupyterPython(); - if (usableInterpreter) { - // See if the usable interpreter is not our active one. If so, show a warning - // Only do this if not the guest in a liveshare session - const api = await this.liveShare.getApi(); - if (!api || (api.session && api.session.role !== vsls.Role.Guest)) { - const active = await this.interpreterService.getActiveInterpreter(); - const activeDisplayName = active ? active.displayName : undefined; - const activePath = active ? active.path : undefined; - const usableDisplayName = usableInterpreter ? usableInterpreter.displayName : undefined; - const usablePath = usableInterpreter ? usableInterpreter.path : undefined; - const notebookError = await this.jupyterExecution.getNotebookError(); - if (activePath && usablePath && !this.fileSystem.arePathsSame(activePath, usablePath) && activeDisplayName && usableDisplayName) { - this.applicationShell.showWarningMessage(localize.DataScience.jupyterKernelNotSupportedOnActive().format(activeDisplayName, usableDisplayName, notebookError)); + const options = await this.getNotebookOptions(); + if (options && !options.uri) { + activeInterpreter = await this.interpreterService.getActiveInterpreter(); + const usableInterpreter = await this.jupyterExecution.getUsableJupyterPython(); + if (usableInterpreter) { + // See if the usable interpreter is not our active one. If so, show a warning + // Only do this if not the guest in a liveshare session + const api = await this.liveShare.getApi(); + if (!api || (api.session && api.session.role !== vsls.Role.Guest)) { + const active = await this.interpreterService.getActiveInterpreter(); + const activeDisplayName = active ? active.displayName : undefined; + const activePath = active ? active.path : undefined; + const usableDisplayName = usableInterpreter ? usableInterpreter.displayName : undefined; + const usablePath = usableInterpreter ? usableInterpreter.path : undefined; + const notebookError = await this.jupyterExecution.getNotebookError(); + if (activePath && usablePath && !this.fileSystem.arePathsSame(activePath, usablePath) && activeDisplayName && usableDisplayName) { + this.applicationShell.showWarningMessage(localize.DataScience.jupyterKernelNotSupportedOnActive().format(activeDisplayName, usableDisplayName, notebookError)); + } } } + return usableInterpreter ? true : false; + } else { + return true; } - - return usableInterpreter ? true : false; - } catch (e) { // Can't find a usable interpreter, show the error. if (activeInterpreter) { diff --git a/src/client/datascience/interactive-common/interactiveWindowTypes.ts b/src/client/datascience/interactive-common/interactiveWindowTypes.ts index 59b3be5b9fae..3c438ca51a56 100644 --- a/src/client/datascience/interactive-common/interactiveWindowTypes.ts +++ b/src/client/datascience/interactive-common/interactiveWindowTypes.ts @@ -27,6 +27,8 @@ export namespace InteractiveWindowMessages { export const Interrupt = 'interrupt'; export const SubmitNewCell = 'submit_new_cell'; export const UpdateSettings = SharedMessages.UpdateSettings; + // Message sent to React component from extension asking it to save the notebook. + export const DoSave = 'DoSave'; export const SendInfo = 'send_info'; export const Started = SharedMessages.Started; export const AddedSysInfo = 'added_sys_info'; @@ -51,6 +53,8 @@ export namespace InteractiveWindowMessages { export const AddCell = 'add_cell'; export const EditCell = 'edit_cell'; export const RemoveCell = 'remove_cell'; + export const SwapCells = 'swap_cells'; + export const InsertCell = 'insert_cell'; export const LoadOnigasmAssemblyRequest = 'load_onigasm_assembly_request'; export const LoadOnigasmAssemblyResponse = 'load_onigasm_assembly_response'; export const LoadTmLanguageRequest = 'load_tmlanguage_request'; @@ -69,7 +73,10 @@ export namespace InteractiveWindowMessages { export const NotebookClean = 'clean'; export const SaveAll = 'save_all'; export const NativeCommand = 'native_command'; - + export const VariablesComplete = 'variables_complete'; + export const NotebookRunAllCells = 'notebook_run_all_cells'; + export const NotebookRunSelectedCell = 'notebook_run_selected_cell'; + export const NotebookAddCellBelow = 'notebook_add_cell_below'; } export enum NativeCommandType { @@ -209,6 +216,17 @@ export interface IRemoveCell { id: string; } +export interface ISwapCells { + firstCellId: string; + secondCellId: string; +} + +export interface IInsertCell { + id: string; + code: string; + codeCellAbove: string | undefined; +} + export interface IShowDataViewer { variableName: string; columnSize: number; @@ -283,6 +301,8 @@ export class IInteractiveWindowMapping { public [InteractiveWindowMessages.AddCell]: IAddCell; public [InteractiveWindowMessages.EditCell]: IEditCell; public [InteractiveWindowMessages.RemoveCell]: IRemoveCell; + public [InteractiveWindowMessages.SwapCells]: ISwapCells; + public [InteractiveWindowMessages.InsertCell]: IInsertCell; public [InteractiveWindowMessages.LoadOnigasmAssemblyRequest]: never | undefined; public [InteractiveWindowMessages.LoadOnigasmAssemblyResponse]: Buffer; public [InteractiveWindowMessages.LoadTmLanguageRequest]: never | undefined; @@ -301,4 +321,8 @@ export class IInteractiveWindowMapping { public [InteractiveWindowMessages.NotebookClean]: never | undefined; public [InteractiveWindowMessages.SaveAll]: ISaveAll; public [InteractiveWindowMessages.NativeCommand]: INativeCommand; + public [InteractiveWindowMessages.VariablesComplete]: never | undefined; + public [InteractiveWindowMessages.NotebookRunAllCells]: never | undefined; + public [InteractiveWindowMessages.NotebookRunSelectedCell]: never | undefined; + public [InteractiveWindowMessages.NotebookAddCellBelow]: never | undefined; } diff --git a/src/client/datascience/interactive-ipynb/autoSaveService.ts b/src/client/datascience/interactive-ipynb/autoSaveService.ts new file mode 100644 index 000000000000..826b2a7c0c1e --- /dev/null +++ b/src/client/datascience/interactive-ipynb/autoSaveService.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { ConfigurationChangeEvent, Event, EventEmitter, TextEditor, Uri } from 'vscode'; +import { IApplicationShell, IDocumentManager, IWorkspaceService } from '../../common/application/types'; +import '../../common/extensions'; +import { traceError } from '../../common/logger'; +import { IFileSystem } from '../../common/platform/types'; +import { IDisposable } from '../../common/types'; +import { INotebookIdentity, InteractiveWindowMessages } from '../interactive-common/interactiveWindowTypes'; +import { FileSettings, IInteractiveWindowListener, INotebookEditor, INotebookEditorProvider } from '../types'; + +// tslint:disable: no-any + +/** + * Sends notifications to Notebooks to save the notebook. + * Based on auto save settings, this class will regularly check for changes and send a save requet. + * If window state changes or active editor changes, then notify notebooks (if auto save is configured to do so). + * Monitor save and modified events on editor to determine its current dirty state. + * + * @export + * @class AutoSaveService + * @implements {IInteractiveWindowListener} + */ +@injectable() +export class AutoSaveService implements IInteractiveWindowListener { + private postEmitter: EventEmitter<{ message: string; payload: any }> = new EventEmitter<{ message: string; payload: any }>(); + private disposables: IDisposable[] = []; + private notebookUri?: Uri; + private timeout?: ReturnType; + constructor( + @inject(IApplicationShell) appShell: IApplicationShell, + @inject(IDocumentManager) documentManager: IDocumentManager, + @inject(INotebookEditorProvider) private readonly notebookProvider: INotebookEditorProvider, + @inject(IFileSystem) private readonly fileSystem: IFileSystem, + @inject(IWorkspaceService) private readonly workspace: IWorkspaceService + ) { + this.workspace.onDidChangeConfiguration(this.onSettingsChanded.bind(this), this, this.disposables); + this.disposables.push(appShell.onDidChangeWindowState(this.onDidChangeWindowState.bind(this))); + this.disposables.push(documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor.bind(this))); + } + + public get postMessage(): Event<{ message: string; payload: any }> { + return this.postEmitter.event; + } + + public onMessage(message: string, payload?: any): void { + if (message === InteractiveWindowMessages.NotebookIdentity) { + this.notebookUri = Uri.parse((payload as INotebookIdentity).resource); + } + if (message === InteractiveWindowMessages.LoadAllCellsComplete) { + const notebook = this.getNotebook(); + if (!notebook) { + traceError(`Received message ${message}, but there is no notebook for ${this.notebookUri ? this.notebookUri.fsPath : undefined}`); + return; + } + this.disposables.push(notebook.modified(this.onNotebookModified, this, this.disposables)); + this.disposables.push(notebook.saved(this.onNotebookSaved, this, this.disposables)); + } + } + public dispose(): void | undefined { + this.disposables.filter(item => !!item).forEach(item => item.dispose()); + this.clearTimeout(); + } + private onNotebookModified(_: INotebookEditor) { + // If we haven't started a timer, then start if necessary. + if (!this.timeout) { + this.setTimer(); + } + } + private onNotebookSaved(_: INotebookEditor) { + // If we haven't started a timer, then start if necessary. + if (!this.timeout) { + this.setTimer(); + } + } + private getNotebook(): INotebookEditor | undefined { + const uri = this.notebookUri; + if (!uri) { + return; + } + return this.notebookProvider.editors.find(item => this.fileSystem.arePathsSame(item.file.fsPath, uri.fsPath)); + } + private getAutoSaveSettings(): FileSettings { + const filesConfig = this.workspace.getConfiguration('files', this.notebookUri); + return { + autoSave: filesConfig.get('autoSave', 'off'), + autoSaveDelay: filesConfig.get('autoSaveDelay', 1000) + }; + } + private onSettingsChanded(e: ConfigurationChangeEvent) { + if (e.affectsConfiguration('files.autoSave') || e.affectsConfiguration('files.autoSaveDelay')) { + // Reset the timer, as we may have increased it, turned it off or other. + this.clearTimeout(); + this.setTimer(); + } + } + private setTimer() { + const settings = this.getAutoSaveSettings(); + if (!settings || settings.autoSave === 'off') { + return; + } + if (settings && settings.autoSave === 'afterDelay') { + // Add a timeout to save after n milli seconds. + // Do not use setInterval, as that will cause all handlers to queue up. + this.timeout = setTimeout(() => { + this.save(); + }, settings.autoSaveDelay); + } + } + private clearTimeout() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = undefined; + } + } + private save() { + this.clearTimeout(); + const notebook = this.getNotebook(); + if (notebook && notebook.isDirty && !notebook.isUntitled) { + // Notify webview to perform a save. + this.postEmitter.fire({ message: InteractiveWindowMessages.DoSave, payload: undefined }); + } else { + this.setTimer(); + } + } + private onDidChangeWindowState() { + const settings = this.getAutoSaveSettings(); + if (settings && settings.autoSave === 'onWindowChange') { + this.save(); + } + } + private onDidChangeActiveTextEditor(_e?: TextEditor) { + const settings = this.getAutoSaveSettings(); + if (settings && settings.autoSave === 'onFocusChange') { + this.save(); + } + } +} diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index ee49ccfbc2c4..846e304d9851 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -3,6 +3,8 @@ 'use strict'; import '../../common/extensions'; +import { nbformat } from '@jupyterlab/coreutils/lib/nbformat'; +import * as detectIndent from 'detect-indent'; import * as fastDeepEqual from 'fast-deep-equal'; import { inject, injectable, multiInject, named } from 'inversify'; import * as path from 'path'; @@ -27,7 +29,7 @@ import { StopWatch } from '../../common/utils/stopWatch'; import { EXTENSION_ROOT_DIR } from '../../constants'; import { IInterpreterService } from '../../interpreter/contracts'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { concatMultilineString } from '../common'; +import { concatMultilineStringInput, splitMultilineString } from '../common'; import { EditorContexts, Identifiers, @@ -43,6 +45,7 @@ import { ISaveAll, ISubmitNewCell } from '../interactive-common/interactiveWindowTypes'; +import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError'; import { CellState, ICell, @@ -74,12 +77,15 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { private closedEvent: EventEmitter = new EventEmitter(); private executedEvent: EventEmitter = new EventEmitter(); private modifiedEvent: EventEmitter = new EventEmitter(); + private savedEvent: EventEmitter = new EventEmitter(); private loadedPromise: Deferred = createDeferred(); private _file: Uri = Uri.file(''); private _dirty: boolean = false; private visibleCells: ICell[] = []; private startupTimer: StopWatch = new StopWatch(); private loadedAllCells: boolean = false; + private indentAmount: string = ' '; + private notebookJson: Partial = {}; constructor( @multiInject(IInteractiveWindowListener) listeners: IInteractiveWindowListener[], @@ -129,7 +135,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { errorHandler, path.join(EXTENSION_ROOT_DIR, 'out', 'datascience-ui', 'native-editor', 'index_bundle.js'), localize.DataScience.nativeEditorTitle(), - ViewColumn.Active); + ViewColumn.Active + ); } public get visible(): boolean { @@ -144,12 +151,16 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this._file; } + public get isUntitled(): boolean { + const baseName = path.basename(this.file.fsPath); + return baseName.includes(localize.DataScience.untitledNotebookFileName()); + } public dispose(): void { super.dispose(); this.close().ignoreErrors(); } - public async load(content: string, file: Uri): Promise { + public async load(contents: string, file: Uri): Promise { // Save our uri this._file = file; @@ -166,12 +177,10 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { const dirtyContents = this.getStoredContents(); if (dirtyContents) { // This means we're dirty. Indicate dirty and load from this content - const cells = await this.importer.importCells(dirtyContents); - return this.loadCells(cells, true); + return this.loadContents(dirtyContents, true); } else { - // Load the contents of this notebook into our cells. - const cells = content ? await this.importer.importCells(content) : []; - return this.loadCells(cells, false); + // Load without setting dirty + return this.loadContents(contents, false); } } @@ -187,6 +196,14 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.modifiedEvent.event; } + public get saved(): Event { + return this.savedEvent.event; + } + + public get isDirty(): boolean { + return this._dirty; + } + // tslint:disable-next-line: no-any public onMessage(message: string, payload: any) { super.onMessage(message, payload); @@ -225,6 +242,18 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { return this.ipynbProvider.getNotebookOptions(); } + public runAllCells() { + this.postMessage(InteractiveWindowMessages.NotebookRunAllCells).ignoreErrors(); + } + + public runSelectedCell() { + this.postMessage(InteractiveWindowMessages.NotebookRunSelectedCell).ignoreErrors(); + } + + public addCellBelow() { + this.postMessage(InteractiveWindowMessages.NotebookAddCellBelow).ignoreErrors(); + } + protected async reopen(cells: ICell[]): Promise { try { super.reload(); @@ -244,7 +273,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // If that works, send the cells to the web view return this.postMessage(InteractiveWindowMessages.LoadAllCells, { cells }); } catch (e) { - this.errorHandler.handleError(e).ignoreErrors(); + return this.errorHandler.handleError(e); } } @@ -257,9 +286,19 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { this.submitCode(info.code, Identifiers.EmptyFileName, 0, info.id).ignoreErrors(); // Activate the other side, and send as if came from a file - this.ipynbProvider.show(this.file).then(_v => { - this.shareMessage(InteractiveWindowMessages.RemoteAddCode, { code: info.code, file: Identifiers.EmptyFileName, line: 0, id: info.id, originator: this.id, debug: false }); - }).ignoreErrors(); + this.ipynbProvider + .show(this.file) + .then(_v => { + this.shareMessage(InteractiveWindowMessages.RemoteAddCode, { + code: info.code, + file: Identifiers.EmptyFileName, + line: 0, + id: info.id, + originator: this.id, + debug: false + }); + }) + .ignoreErrors(); } } @@ -277,13 +316,43 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Activate the other side, and send as if came from a file await this.ipynbProvider.show(this.file); - this.shareMessage(InteractiveWindowMessages.RemoteReexecuteCode, { code: info.code, file: Identifiers.EmptyFileName, line: 0, id: info.id, originator: this.id, debug: false }); + this.shareMessage(InteractiveWindowMessages.RemoteReexecuteCode, { + code: info.code, + file: Identifiers.EmptyFileName, + line: 0, + id: info.id, + originator: this.id, + debug: false + }); } } catch (exc) { - await this.errorHandler.handleError(exc); + // Make this error our cell output + this.sendCellsToWebView([ + { + data: { + source: info.code, + cell_type: 'code', + outputs: [ + { + output_type: 'error', + evalue: exc.toString() + } + ], + metadata: {}, + execution_count: null + }, + id: info.id, + file: Identifiers.EmptyFileName, + line: 0, + state: CellState.error + } + ]); // Tell the other side we restarted the kernel. This will stop all executions this.postMessage(InteractiveWindowMessages.RestartKernel).ignoreErrors(); + + // Handle an error + await this.errorHandler.handleError(exc); } } @@ -317,10 +386,13 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { interactiveContext.set(!this.isDisposed).catch(); const interactiveCellsContext = new ContextKey(EditorContexts.HaveNativeCells, this.commandManager); const redoableContext = new ContextKey(EditorContexts.HaveNativeRedoableCells, this.commandManager); + const hasCellSelectedContext = new ContextKey(EditorContexts.HaveCellSelected, this.commandManager); if (info) { interactiveCellsContext.set(info.cellCount > 0).catch(); redoableContext.set(info.redoCount > 0).catch(); + hasCellSelectedContext.set(info.selectedCell ? true : false).catch(); } else { + hasCellSelectedContext.set(false).catch(); interactiveCellsContext.set(false).catch(); redoableContext.set(false).catch(); } @@ -344,6 +416,68 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { // Actually don't close, just let the error bubble out } + private async loadContents(contents: string | undefined, forceDirty: boolean): Promise { + // tslint:disable-next-line: no-any + const json = contents ? JSON.parse(contents) as any : undefined; + + // Double check json (if we have any) + if (json && !json.cells) { + throw new InvalidNotebookFileError(this.file.fsPath); + } + + // Then compute indent. It's computed from the contents + if (contents) { + this.indentAmount = detectIndent(contents).indent; + } + + // Then save the contents. We'll stick our cells back into this format when we save + if (json) { + this.notebookJson = json; + } else { + const pythonNumber = await this.extractPythonMainVersion(this.notebookJson); + // Use this to build our metadata object + // Use these as the defaults unless we have been given some in the options. + const metadata: nbformat.INotebookMetadata = { + language_info: { + name: 'python', + codemirror_mode: { + name: 'ipython', + version: pythonNumber + } + }, + orig_nbformat: 2, + file_extension: '.py', + mimetype: 'text/x-python', + name: 'python', + npconvert_exporter: 'python', + pygments_lexer: `ipython${pythonNumber}`, + version: pythonNumber + }; + + // Default notebook data. + this.notebookJson = { + nbformat: 4, + nbformat_minor: 2, + metadata: metadata + }; + } + + // Extract cells from the json + const cells = contents ? json.cells as (nbformat.ICodeCell | nbformat.IRawCell | nbformat.IMarkdownCell)[] : []; + + // Then parse the cells + return this.loadCells(cells.map((c, index) => { + return { + id: `NotebookImport#${index}`, + file: Identifiers.EmptyFileName, + line: 0, + state: CellState.finished, + data: c + }; + }), forceDirty); + + } + private async loadCells(cells: ICell[], forceDirty: boolean): Promise { // Make sure cells have at least 1 if (cells.length === 0) { @@ -352,13 +486,11 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { line: 0, file: Identifiers.EmptyFileName, state: CellState.finished, - type: 'execute', data: { cell_type: 'code', outputs: [], source: [], - metadata: { - }, + metadata: {}, execution_count: null } }; @@ -450,7 +582,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { const cell = this.visibleCells.find(c => c.id === request.id); if (cell) { // This is an actual edit. - const contents = concatMultilineString(cell.data.source); + const contents = concatMultilineStringInput(cell.data.source); const before = contents.substr(0, change.rangeOffset); const after = contents.substr(change.rangeOffset + change.rangeLength); const newContents = `${before}${normalized}${after}`; @@ -485,9 +617,10 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { if (!fastDeepEqual(this.visibleCells, cells)) { this.visibleCells = cells; - // Save our dirty state in the storage for reopen later - const notebook = await this.jupyterExporter.translateToNotebook(this.visibleCells, undefined); - await this.storeContents(JSON.stringify(notebook)); + // Save our dirty state in the storage for reopen later. + // Do not block current code, hence let this run in the background. + this.storeContents(this.generateNotebookContent(cells)) + .catch(ex => traceError('Failed to generate notebook content to store in state', ex)); // Indicate dirty await this.setDirty(); @@ -522,17 +655,13 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { tempFile = await this.fileSystem.createTemporaryFile('.ipynb'); // Translate the cells into a notebook - const notebook = await this.jupyterExporter.translateToNotebook(cells, undefined); - - // Write the cells to this file - await this.fileSystem.writeFile(tempFile.filePath, JSON.stringify(notebook), { encoding: 'utf-8' }); + await this.fileSystem.writeFile(tempFile.filePath, this.generateNotebookContent(cells), { encoding: 'utf-8' }); // Import this file and show it const contents = await this.importer.importFromFile(tempFile.filePath); if (contents) { await this.viewDocument(contents); } - } catch (e) { await this.errorHandler.handleError(e); } finally { @@ -548,15 +677,45 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { await this.documentManager.showTextDocument(doc, ViewColumn.One); } + private fixupCell(cell: nbformat.ICell): nbformat.ICell { + // Source is usually a single string on input. Convert back to an array + return { + ...cell, + source: splitMultilineString(cell.source) + }; + } + + private async extractPythonMainVersion(notebookData: Partial): Promise { + if (notebookData && notebookData.metadata && + notebookData.metadata.language_info && + notebookData.metadata.language_info.codemirror_mode && + // tslint:disable-next-line: no-any + typeof (notebookData.metadata.language_info.codemirror_mode as any).version === 'number'){ + + // tslint:disable-next-line: no-any + return (notebookData.metadata.language_info.codemirror_mode as any).version; + } + // Use the active interpreter + const usableInterpreter = await this.jupyterExecution.getUsableJupyterPython(); + return usableInterpreter && usableInterpreter.version ? usableInterpreter.version.major : 3; + } + + private generateNotebookContent(cells: ICell[]): string { + // Reuse our original json except for the cells. + const json = { + ...(this.notebookJson as nbformat.INotebookContent), + cells: cells.map(c => this.fixupCell(c.data)) + }; + return JSON.stringify(json, null, this.indentAmount); + } + private async saveToDisk(): Promise { try { let fileToSaveTo: Uri | undefined = this.file; let isDirty = this._dirty; // Ask user for a save as dialog if no title - const baseName = path.basename(this.file.fsPath); - const isUntitled = baseName.includes(localize.DataScience.untitledNotebookFileName()); - if (isUntitled) { + if (this.isUntitled) { const filtersKey = localize.DataScience.dirtyNotebookDialogFilter(); const filtersObject: { [name: string]: string[] } = {}; filtersObject[filtersKey] = ['ipynb']; @@ -564,21 +723,19 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { fileToSaveTo = await this.applicationShell.showSaveDialog({ saveLabel: localize.DataScience.dirtyNotebookDialogTitle(), - filters: filtersObject, - defaultUri: isUntitled ? undefined : this.file + filters: filtersObject }); } if (fileToSaveTo && isDirty) { - // Save our visible cells into the file - const notebook = await this.jupyterExporter.translateToNotebook(this.visibleCells, undefined); - await this.fileSystem.writeFile(fileToSaveTo.fsPath, JSON.stringify(notebook)); + // Write out our visible cells + await this.fileSystem.writeFile(fileToSaveTo.fsPath, this.generateNotebookContent(this.visibleCells)); // Update our file name and dirty state this._file = fileToSaveTo; await this.setClean(); + this.savedEvent.fire(this); } - } catch (e) { traceError(e); } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts b/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts index 97636f9366e2..ef299391a252 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorCommandListener.ts @@ -32,6 +32,30 @@ export class NativeEditorCommandListener implements IDataScienceCommandListener this.disposableRegistry.push(commandManager.registerCommand(Commands.NotebookEditorInterruptKernel, () => this.interruptKernel())); this.disposableRegistry.push(commandManager.registerCommand(Commands.NotebookEditorRestartKernel, () => this.restartKernel())); this.disposableRegistry.push(commandManager.registerCommand(Commands.OpenNotebook, (file?: Uri, _cmdSource: CommandSource = CommandSource.commandPalette) => this.openNotebook(file))); + this.disposableRegistry.push(commandManager.registerCommand(Commands.NotebookEditorRunAllCells, () => this.runAllCells())); + this.disposableRegistry.push(commandManager.registerCommand(Commands.NotebookEditorRunSelectedCell, () => this.runSelectedCell())); + this.disposableRegistry.push(commandManager.registerCommand(Commands.NotebookEditorAddCellBelow, () => this.addCellBelow())); + } + + private runAllCells() { + const activeEditor = this.provider.activeEditor; + if (activeEditor) { + activeEditor.runAllCells(); + } + } + + private runSelectedCell() { + const activeEditor = this.provider.activeEditor; + if (activeEditor) { + activeEditor.runSelectedCell(); + } + } + + private addCellBelow() { + const activeEditor = this.provider.activeEditor; + if (activeEditor) { + activeEditor.addCellBelow(); + } } private undoCells() { @@ -77,7 +101,7 @@ export class NativeEditorCommandListener implements IDataScienceCommandListener // Then take the contents and load it. await this.provider.open(file, contents); } catch (e) { - this.dataScienceErrorHandler.handleError(e).ignoreErrors(); + return this.dataScienceErrorHandler.handleError(e); } } } diff --git a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts index 4a13e5ef9422..df1d675af51b 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditorProvider.ts @@ -3,7 +3,7 @@ 'use strict'; import { inject, injectable } from 'inversify'; import * as path from 'path'; -import { TextDocument, Uri } from 'vscode'; +import { TextDocument, TextEditor, Uri } from 'vscode'; import { ICommandManager, IDocumentManager, IWorkspaceService } from '../../common/application/types'; import { JUPYTER_LANGUAGE } from '../../common/constants'; @@ -21,7 +21,6 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp private executedEditors: Set = new Set(); private notebookCount: number = 0; private openedNotebookCount: number = 0; - constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, @@ -45,16 +44,14 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp findFilesPromise.then(r => this.notebookCount += r.length); } - // Listen to document open commands. We use this to launch an ipynb editor - const disposable = this.documentManager.onDidOpenTextDocument(this.onOpenedDocument); - this.disposables.push(disposable); + this.disposables.push(this.documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditorHandler.bind(this))); // Since we may have activated after a document was opened, also run open document for all documents. // This needs to be async though. Iterating over all of these in the .ctor is crashing the extension // host, so postpone till after the ctor is finished. setTimeout(() => { if (this.documentManager.textDocuments && this.documentManager.textDocuments.forEach) { - this.documentManager.textDocuments.forEach(this.onOpenedDocument); + this.documentManager.textDocuments.forEach(doc => this.openNotebookAndCloseEditor(doc, false)); } }, 0); @@ -132,6 +129,19 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp purpose: Identifiers.HistoryPurpose // Share the same one as the interactive window. Just need a new session }; } + /** + * Open ipynb files when user opens an ipynb file. + * + * @private + * @memberof NativeEditorProvider + */ + private onDidChangeActiveTextEditorHandler(editor?: TextEditor){ + // I we're a source control diff view, then ignore this editor. + if (!editor || this.isEditorPartOfDiffView(editor)){ + return; + } + this.openNotebookAndCloseEditor(editor.document, true).ignoreErrors(); + } private async create(file: Uri, contents: string): Promise { const editor = this.serviceContainer.get(INotebookEditor); @@ -178,28 +188,83 @@ export class NativeEditorProvider implements INotebookEditorProvider, IAsyncDisp return Uri.file(`${localize.DataScience.untitledNotebookFileName()}-${number}`); } - private onOpenedDocument = async (document: TextDocument) => { + private openNotebookAndCloseEditor = async (document: TextDocument, closeDocumentBeforeOpeningNotebook: boolean) => { // See if this is an ipynb file - if (this.isNotebook(document) && this.configuration.getSettings().datascience.useNotebookEditor) { + if (this.isNotebook(document) && this.configuration.getSettings().datascience.useNotebookEditor && + !this.activeEditors.has(document.uri.fsPath)) { try { - const contents = document.getText(); + const contents = document. getText(); const uri = document.uri; + const closeActiveEditorCommand = 'workbench.action.closeActiveEditor'; + + if (closeDocumentBeforeOpeningNotebook) { + if (!this.documentManager.activeTextEditor || this.documentManager.activeTextEditor.document !== document){ + await this.documentManager.showTextDocument(document); + } + await this.cmdManager.executeCommand(closeActiveEditorCommand); + } // Open our own editor. await this.open(uri, contents); - // Then switch back to the ipynb and close it. - // If we don't do it in this order, the close will switch to the wrong item - await this.documentManager.showTextDocument(document); - const command = 'workbench.action.closeActiveEditor'; - await this.cmdManager.executeCommand(command); + if (!closeDocumentBeforeOpeningNotebook){ + // Then switch back to the ipynb and close it. + // If we don't do it in this order, the close will switch to the wrong item + await this.documentManager.showTextDocument(document); + await this.cmdManager.executeCommand(closeActiveEditorCommand); + } } catch (e) { - this.dataScienceErrorHandler.handleError(e).ignoreErrors(); + return this.dataScienceErrorHandler.handleError(e); } } } + /** + * Check if user is attempting to compare two ipynb files. + * If yes, then return `true`, else `false`. + * + * @private + * @param {TextEditor} editor + * @memberof NativeEditorProvider + */ + private isEditorPartOfDiffView(editor?: TextEditor){ + if (!editor){ + return false; + } + // There's no easy way to determine if the user is openeing a diff view. + // One simple way is to check if there are 2 editor opened, and if both editors point to the same file + // One file with the `file` scheme and the other with the `git` scheme. + if (this.documentManager.visibleTextEditors.length <= 1){ + return false; + } + + // If we have both `git` & `file` schemes for the same file, then we're most likely looking at a diff view. + // Also ensure both editors are in the same view column. + // Possible we have a git diff view (with two editors git and file scheme), and we open the file view + // on the side (different view column). + const gitSchemeEditor = this.documentManager.visibleTextEditors.find(editorUri => + editorUri.document.uri.scheme === 'git' && + this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath)); + + if (!gitSchemeEditor){ + return false; + } + const fileSchemeEditor = this.documentManager.visibleTextEditors.find(editorUri => + editorUri.document.uri.scheme === 'file' && + this.fileSystem.arePathsSame(editorUri.document.uri.fsPath, editor.document.uri.fsPath) && + editorUri.viewColumn === gitSchemeEditor.viewColumn); + if (!fileSchemeEditor){ + return false; + } + + // Also confirm the document we have passed in, belongs to one of the editors. + // If its not, then its another document (that is not in the diff view). + return gitSchemeEditor === editor || fileSchemeEditor === editor; + } private isNotebook(document: TextDocument) { - return document.languageId === JUPYTER_LANGUAGE || path.extname(document.fileName).toLocaleLowerCase() === '.ipynb'; + // Only support file uris (we don't want to automatically open any other ipynb file from another resource as a notebook). + // E.g. when opening a document for comparison, the scheme is `git`, in live share the scheme is `vsls`. + const validUriScheme = document.uri.scheme === 'file' || document.uri.scheme === 'vsls'; + return validUriScheme && (document.languageId === JUPYTER_LANGUAGE || path.extname(document.fileName).toLocaleLowerCase() === '.ipynb'); } } diff --git a/src/client/datascience/interactive-window/interactiveWindow.ts b/src/client/datascience/interactive-window/interactiveWindow.ts index 2be69ec9d633..49948b200efe 100644 --- a/src/client/datascience/interactive-window/interactiveWindow.ts +++ b/src/client/datascience/interactive-window/interactiveWindow.ts @@ -118,7 +118,7 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi } public addMessage(message: string): Promise { - this.addMessageImpl(message, 'execute'); + this.addMessageImpl(message); return Promise.resolve(); } @@ -235,12 +235,15 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi interactiveContext.set(!this.isDisposed).catch(); const interactiveCellsContext = new ContextKey(EditorContexts.HaveInteractiveCells, this.commandManager); const redoableContext = new ContextKey(EditorContexts.HaveRedoableCells, this.commandManager); + const hasCellSelectedContext = new ContextKey(EditorContexts.HaveCellSelected, this.commandManager); if (info) { interactiveCellsContext.set(info.cellCount > 0).catch(); redoableContext.set(info.redoCount > 0).catch(); + hasCellSelectedContext.set(info.selectedCell ? true : false).catch(); } else { interactiveCellsContext.set(false).catch(); redoableContext.set(false).catch(); + hasCellSelectedContext.set(false).catch(); } } } diff --git a/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts b/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts index 45db25118975..4933f6dde117 100644 --- a/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts +++ b/src/client/datascience/interactive-window/interactiveWindowCommandListener.ts @@ -233,10 +233,10 @@ export class InteractiveWindowCommandListener implements IDataScienceCommandList } } } else { - this.dataScienceErrorHandler.handleError( + await this.dataScienceErrorHandler.handleError( new JupyterInstallError( localize.DataScience.jupyterNotSupported().format(await this.jupyterExecution.getNotebookError()), - localize.DataScience.pythonInteractiveHelpLink())).ignoreErrors(); + localize.DataScience.pythonInteractiveHelpLink())); } } diff --git a/src/client/datascience/jupyter/jupyterCommandFinder.ts b/src/client/datascience/jupyter/jupyterCommandFinder.ts index 7ae172b21d4c..28175ef4616f 100644 --- a/src/client/datascience/jupyter/jupyterCommandFinder.ts +++ b/src/client/datascience/jupyter/jupyterCommandFinder.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { CancellationToken } from 'vscode'; import { IWorkspaceService } from '../../common/application/types'; -import { Cancellation } from '../../common/cancellation'; +import { Cancellation, createPromiseFromCancellation } from '../../common/cancellation'; import { traceInfo, traceWarning } from '../../common/logger'; import { IFileSystem } from '../../common/platform/types'; import { IProcessService, IProcessServiceFactory, IPythonExecutionFactory, SpawnOptions } from '../../common/process/types'; @@ -33,6 +33,18 @@ export interface IFindCommandResult extends IModuleExistsResult { command?: IJupyterCommand; } +const cancelledResult: IFindCommandResult = { + status: ModuleExistsStatus.NotFound, + error: localize.DataScience.noInterpreter() +}; + +function isCommandFinderCancelled(command: JupyterCommands, token?: CancellationToken) { + if (Cancellation.isCanceled(token)) { + traceInfo(`Command finder cancelled for ${command}.`); + return true; + } + return false; +} export class JupyterCommandFinder { private readonly processServicePromise: Promise; private jupyterPath?: string; @@ -152,7 +164,7 @@ export class JupyterCommandFinder { // - Look for module in current interpreter, if found create something with python path and -m module // - Look in other interpreters, if found create something with python path and -m module // - Look on path for jupyter, if found create something with jupyter path and args - // tslint:disable:cyclomatic-complexity + // tslint:disable:cyclomatic-complexity max-func-body-length private async findBestCommandImpl(command: JupyterCommands, cancelToken?: CancellationToken): Promise { let found: IFindCommandResult = { status: ModuleExistsStatus.NotFound @@ -165,6 +177,11 @@ export class JupyterCommandFinder { // First we look in the current interpreter const current = await this.interpreterService.getActiveInterpreter(); + + if (isCommandFinderCancelled(command, cancelToken)) { + return cancelledResult; + } + found = current ? await this.findInterpreterCommand(command, current, cancelToken) : found; if (found.status === ModuleExistsStatus.NotFound) { traceInfo(`Active interpreter does not support ${command} because of error ${found.error}. Interpreter is ${current ? current.displayName : 'undefined'}.`); @@ -174,14 +191,24 @@ export class JupyterCommandFinder { } if (found.status === ModuleExistsStatus.NotFound && this.supportsSearchingForCommands()) { // Look through all of our interpreters (minus the active one at the same time) - const all = await this.interpreterService.getInterpreters(); + const cancelGetInterpreters = createPromiseFromCancellation({ defaultValue: [], cancelAction: 'resolve', token: cancelToken }); + const all = await Promise.race([this.interpreterService.getInterpreters(), cancelGetInterpreters]); + + if (isCommandFinderCancelled(command, cancelToken)) { + return cancelledResult; + } if (!all || all.length === 0) { traceWarning('No interpreters found. Jupyter cannot run.'); } + const cancelFind = createPromiseFromCancellation({ defaultValue: [], cancelAction: 'resolve', token: cancelToken }); const promises = all.filter(i => i !== current).map(i => this.findInterpreterCommand(command, i, cancelToken)); - const foundList = await Promise.all(promises); + const foundList = await Promise.race([Promise.all(promises), cancelFind]); + + if (isCommandFinderCancelled(command, cancelToken)) { + return cancelledResult; + } // Then go through all of the found ones and pick the closest python match if (current && current.version) { @@ -192,6 +219,10 @@ export class JupyterCommandFinder { continue; } const interpreter = await entry.command.interpreter(); + if (isCommandFinderCancelled(command, cancelToken)) { + return cancelledResult; + } + const version = interpreter ? interpreter.version : undefined; if (version) { if (version.major === current.version.major) { diff --git a/src/client/datascience/jupyter/jupyterDebugger.ts b/src/client/datascience/jupyter/jupyterDebugger.ts index 5d1de2542240..56afb727606e 100644 --- a/src/client/datascience/jupyter/jupyterDebugger.ts +++ b/src/client/datascience/jupyter/jupyterDebugger.ts @@ -13,7 +13,7 @@ import { IPlatformService } from '../../common/platform/types'; import { IConfigurationService } from '../../common/types'; import * as localize from '../../common/utils/localize'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { concatMultilineString } from '../common'; +import { concatMultilineStringOutput } from '../common'; import { Identifiers, Telemetry } from '../constants'; import { CellState, @@ -398,7 +398,7 @@ export class JupyterDebugger implements IJupyterDebugger, ICellHashListener { } if (outputs[0].output_type === 'stream') { const stream = outputs[0] as nbformat.IStream; - return concatMultilineString(stream.text); + return concatMultilineStringOutput(stream.text); } } } diff --git a/src/client/datascience/jupyter/jupyterExecution.ts b/src/client/datascience/jupyter/jupyterExecution.ts index ebe024899fbd..2ac27ba96055 100644 --- a/src/client/datascience/jupyter/jupyterExecution.ts +++ b/src/client/datascience/jupyter/jupyterExecution.ts @@ -34,7 +34,7 @@ import { INotebookServerLaunchInfo, INotebookServerOptions } from '../types'; -import {IFindCommandResult, JupyterCommandFinder} from './jupyterCommandFinder'; +import { IFindCommandResult, JupyterCommandFinder } from './jupyterCommandFinder'; import { JupyterConnection, JupyterServerInfo } from './jupyterConnection'; import { JupyterInstallError } from './jupyterInstallError'; import { JupyterKernelSpec } from './jupyterKernelSpec'; @@ -94,7 +94,7 @@ export class JupyterExecutionBase implements IJupyterExecution { } public async getNotebookError(): Promise { - const notebook = await this.commandFinder.findBestCommand(JupyterCommands.NotebookCommand); + const notebook = await this.findBestCommand(JupyterCommands.NotebookCommand); return notebook.error ? notebook.error : localize.DataScience.notebookNotFound(); } @@ -200,7 +200,7 @@ export class JupyterExecutionBase implements IJupyterExecution { public async spawnNotebook(file: string): Promise { // First we find a way to start a notebook server - const notebookCommand = await this.findBestCommandTimed(JupyterCommands.NotebookCommand); + const notebookCommand = await this.findBestCommand(JupyterCommands.NotebookCommand); this.checkNotebookCommand(notebookCommand); const args: string[] = [`--NotebookApp.file_to_run=${file}`]; @@ -211,7 +211,7 @@ export class JupyterExecutionBase implements IJupyterExecution { public async importNotebook(file: string, template: string | undefined): Promise { // First we find a way to start a nbconvert - const convert = await this.findBestCommandTimed(JupyterCommands.ConvertCommand); + const convert = await this.findBestCommand(JupyterCommands.ConvertCommand); if (!convert.command) { throw new Error(localize.DataScience.jupyterNbConvertNotSupported()); } @@ -270,6 +270,10 @@ export class JupyterExecutionBase implements IJupyterExecution { } } + protected async findBestCommand(command: JupyterCommands, cancelToken?: CancellationToken): Promise { + return this.commandFinder.findBestCommand(command, cancelToken); + } + private checkNotebookCommand(notebook: IFindCommandResult) { if (!notebook.command) { const errorMessage = notebook.error ? notebook.error : localize.DataScience.notebookNotFound(); @@ -345,7 +349,7 @@ export class JupyterExecutionBase implements IJupyterExecution { @captureTelemetry(Telemetry.StartJupyter) private async startNotebookServer(useDefaultConfig: boolean, cancelToken?: CancellationToken): Promise<{ connection: IConnection; kernelSpec: IJupyterKernelSpec | undefined }> { // First we find a way to start a notebook server - const notebookCommand = await this.findBestCommandTimed(JupyterCommands.NotebookCommand, cancelToken); + const notebookCommand = await this.findBestCommand(JupyterCommands.NotebookCommand, cancelToken); this.checkNotebookCommand(notebookCommand); // Now actually launch it @@ -452,7 +456,7 @@ export class JupyterExecutionBase implements IJupyterExecution { private getUsableJupyterPythonImpl = async (cancelToken?: CancellationToken): Promise => { // This should be the best interpreter for notebooks - const found = await this.findBestCommandTimed(JupyterCommands.NotebookCommand, cancelToken); + const found = await this.findBestCommand(JupyterCommands.NotebookCommand, cancelToken); if (found && found.command) { return found.command.interpreter(); } @@ -490,7 +494,7 @@ export class JupyterExecutionBase implements IJupyterExecution { private async addMatchingSpec(bestInterpreter: PythonInterpreter, cancelToken?: CancellationToken): Promise { const displayName = localize.DataScience.historyTitle(); - const ipykernelCommand = await this.findBestCommandTimed(JupyterCommands.KernelCreateCommand, cancelToken); + const ipykernelCommand = await this.findBestCommand(JupyterCommands.KernelCreateCommand, cancelToken); // If this fails, then we just skip this spec try { @@ -571,7 +575,7 @@ export class JupyterExecutionBase implements IJupyterExecution { private isCommandSupported = async (command: JupyterCommands, cancelToken?: CancellationToken): Promise => { // See if we can find the command try { - const result = await this.findBestCommandTimed(command, cancelToken); + const result = await this.findBestCommand(command, cancelToken); return result.command !== undefined; } catch (err) { this.logger.logWarning(err); @@ -735,7 +739,7 @@ export class JupyterExecutionBase implements IJupyterExecution { private enumerateSpecs = async (_cancelToken?: CancellationToken): Promise<(JupyterKernelSpec | undefined)[]> => { if (await this.isKernelSpecSupported()) { - const kernelSpecCommand = await this.findBestCommandTimed(JupyterCommands.KernelSpecCommand); + const kernelSpecCommand = await this.findBestCommand(JupyterCommands.KernelSpecCommand); if (kernelSpecCommand.command) { try { @@ -768,8 +772,4 @@ export class JupyterExecutionBase implements IJupyterExecution { return []; } - - private async findBestCommandTimed(command: JupyterCommands, cancelToken?: CancellationToken): Promise { - return this.commandFinder.findBestCommand(command, cancelToken); - } } diff --git a/src/client/datascience/jupyter/jupyterExporter.ts b/src/client/datascience/jupyter/jupyterExporter.ts index 899c61d82766..89fed8dfbbf5 100644 --- a/src/client/datascience/jupyter/jupyterExporter.ts +++ b/src/client/datascience/jupyter/jupyterExporter.ts @@ -13,7 +13,7 @@ import { IConfigurationService } from '../../common/types'; import * as localize from '../../common/utils/localize'; import { noop } from '../../common/utils/misc'; import { CellMatcher } from '../cellMatcher'; -import { concatMultilineString } from '../common'; +import { concatMultilineStringInput } from '../common'; import { CodeSnippits, Identifiers } from '../constants'; import { CellState, ICell, IJupyterExecution, INotebookExporter } from '../types'; @@ -89,8 +89,7 @@ export class JupyterExporter implements INotebookExporter { id: uuid(), file: Identifiers.EmptyFileName, line: 0, - state: CellState.finished, - type: 'execute' + state: CellState.finished }; return [cell, ...cells]; @@ -121,7 +120,7 @@ export class JupyterExporter implements INotebookExporter { private calculateDirectoryChange = async (notebookFile: string, cells: ICell[]): Promise => { // Make sure we don't already have a cell with a ChangeDirectory comment in it. let directoryChange: string | undefined; - const haveChangeAlready = cells.find(c => concatMultilineString(c.data.source).includes(CodeSnippits.ChangeDirectoryCommentIdentifier)); + const haveChangeAlready = cells.find(c => concatMultilineStringInput(c.data.source).includes(CodeSnippits.ChangeDirectoryCommentIdentifier)); if (!haveChangeAlready) { const notebookFilePath = path.dirname(notebookFile); // First see if we have a workspace open, this only works if we have a workspace root to be relative to @@ -165,7 +164,7 @@ export class JupyterExporter implements INotebookExporter { } private pruneSource = (source: nbformat.MultilineString, cellMatcher: CellMatcher): nbformat.MultilineString => { - + // Remove the comments on the top if there. if (Array.isArray(source) && source.length > 0) { if (cellMatcher.isCell(source[0])) { return source.slice(1); diff --git a/src/client/datascience/jupyter/jupyterImporter.ts b/src/client/datascience/jupyter/jupyterImporter.ts index e4a61b6ca269..2fcf89670d2f 100644 --- a/src/client/datascience/jupyter/jupyterImporter.ts +++ b/src/client/datascience/jupyter/jupyterImporter.ts @@ -6,6 +6,7 @@ import * as fs from 'fs-extra'; import { inject, injectable } from 'inversify'; import * as os from 'os'; import * as path from 'path'; +import '../../common/extensions'; import { IWorkspaceService } from '../../common/application/types'; import { IFileSystem, IPlatformService } from '../../common/platform/types'; @@ -20,16 +21,16 @@ import { InvalidNotebookFileError } from './invalidNotebookFileError'; export class JupyterImporter implements INotebookImporter { public isDisposed: boolean = false; // Template that changes markdown cells to have # %% [markdown] in the comments - private readonly nbconvertTemplate = + private readonly nbconvertTemplateFormat = // tslint:disable-next-line:no-multiline-string `{%- extends 'null.tpl' -%} {% block codecell %} -#%% +{0} {{ super() }} {% endblock codecell %} {% block in_prompt %}{% endblock in_prompt %} {% block input %}{{ cell.source | ipython2python }}{% endblock input %} -{% block markdowncell scoped %}#%% [markdown] +{% block markdowncell scoped %}{0} [markdown] {{ cell.source | comment_lines }} {% endblock markdowncell %}`; @@ -116,16 +117,20 @@ export class JupyterImporter implements INotebookImporter { } private addInstructionComments = (pythonOutput: string): string => { - const comments = localize.DataScience.instructionComments(); + const comments = localize.DataScience.instructionComments().format(this.defaultCellMarker); return comments.concat(pythonOutput); } + private get defaultCellMarker(): string { + return this.configuration.getSettings().datascience.defaultCellMarker || Identifiers.DefaultCodeCellMarker; + } + private addIPythonImport = (pythonOutput: string): string => { - return CodeSnippits.ImportIPython.concat(pythonOutput); + return CodeSnippits.ImportIPython.format(this.defaultCellMarker, pythonOutput); } private addDirectoryChange = (pythonOutput: string, directoryChange: string): string => { - const newCode = CodeSnippits.ChangeDirectory.join(os.EOL).format(localize.DataScience.importChangeDirectoryComment(), CodeSnippits.ChangeDirectoryCommentIdentifier, directoryChange); + const newCode = CodeSnippits.ChangeDirectory.join(os.EOL).format(localize.DataScience.importChangeDirectoryComment().format(this.defaultCellMarker), CodeSnippits.ChangeDirectoryCommentIdentifier, directoryChange); return newCode.concat(pythonOutput); } @@ -173,7 +178,7 @@ export class JupyterImporter implements INotebookImporter { try { // Save this file into our disposables so the temp file goes away this.disposableRegistry.push(file); - await fs.appendFile(file.filePath, this.nbconvertTemplate); + await fs.appendFile(file.filePath, this.nbconvertTemplateFormat.format(this.defaultCellMarker)); // Now we should have a template that will convert return file.filePath; diff --git a/src/client/datascience/jupyter/jupyterNotebook.ts b/src/client/datascience/jupyter/jupyterNotebook.ts index f27fdc4b8031..f67602f25612 100644 --- a/src/client/datascience/jupyter/jupyterNotebook.ts +++ b/src/client/datascience/jupyter/jupyterNotebook.ts @@ -23,7 +23,7 @@ import { StopWatch } from '../../common/utils/stopWatch'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { generateCells } from '../cellFactory'; import { CellMatcher } from '../cellMatcher'; -import { concatMultilineString, formatStreamText } from '../common'; +import { concatMultilineStringInput, concatMultilineStringOutput, formatStreamText } from '../common'; import { CodeSnippits, Identifiers, Telemetry } from '../constants'; import { CellState, @@ -301,8 +301,7 @@ export class JupyterNotebookBase implements INotebook { id: uuid(), file: '', line: 0, - state: CellState.finished, - type: 'execute' + state: CellState.finished }; } @@ -482,7 +481,7 @@ export class JupyterNotebookBase implements INotebook { outputs.forEach(o => { if (o.output_type === 'stream') { const stream = o as nbformat.IStream; - result = result.concat(formatStreamText(concatMultilineString(stream.text))); + result = result.concat(formatStreamText(concatMultilineStringOutput(stream.text))); } else { const data = o.data; if (data && data.hasOwnProperty('text/plain')) { @@ -622,7 +621,7 @@ export class JupyterNotebookBase implements INotebook { subscriber.error(this.sessionStartTime, new Error(localize.DataScience.jupyterServerCrashed().format(exitCode.toString()))); subscriber.complete(this.sessionStartTime); } else { - const request = this.generateRequest(concatMultilineString(subscriber.cell.data.source), silent); + const request = this.generateRequest(concatMultilineStringInput(subscriber.cell.data.source), silent); // tslint:disable-next-line:no-require-imports const jupyterLab = require('@jupyterlab/services') as typeof import('@jupyterlab/services'); @@ -807,7 +806,7 @@ export class JupyterNotebookBase implements INotebook { } else { // tslint:disable-next-line:restrict-plus-operands existing.text = existing.text + msg.content.text; - existing.text = trimFunc(formatStreamText(concatMultilineString(existing.text))); + existing.text = trimFunc(formatStreamText(concatMultilineStringOutput(existing.text))); } } else { @@ -815,7 +814,7 @@ export class JupyterNotebookBase implements INotebook { const output: nbformat.IStream = { output_type: 'stream', name: msg.content.name, - text: trimFunc(formatStreamText(concatMultilineString(msg.content.text))) + text: trimFunc(formatStreamText(concatMultilineStringOutput(msg.content.text))) }; this.addToCellData(cell, output, clearState); } diff --git a/src/client/datascience/serviceRegistry.ts b/src/client/datascience/serviceRegistry.ts index ffba92db2bc1..435b6285d99b 100644 --- a/src/client/datascience/serviceRegistry.ts +++ b/src/client/datascience/serviceRegistry.ts @@ -25,6 +25,7 @@ import { DotNetIntellisenseProvider } from './interactive-common/intellisense/do import { JediIntellisenseProvider } from './interactive-common/intellisense/jediIntellisenseProvider'; import { LinkProvider } from './interactive-common/linkProvider'; import { ShowPlotListener } from './interactive-common/showPlotListener'; +import { AutoSaveService } from './interactive-ipynb/autoSaveService'; import { NativeEditor } from './interactive-ipynb/nativeEditor'; import { NativeEditorCommandListener } from './interactive-ipynb/nativeEditorCommandListener'; import { NativeEditorProvider } from './interactive-ipynb/nativeEditorProvider'; @@ -123,6 +124,7 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.add(IInteractiveWindowListener, wrapType(ShowPlotListener)); serviceManager.add(IInteractiveWindowListener, wrapType(DebugListener)); serviceManager.add(IInteractiveWindowListener, wrapType(GatherListener)); + serviceManager.add(IInteractiveWindowListener, wrapType(AutoSaveService)); serviceManager.addSingleton(IPlotViewerProvider, wrapType(PlotViewerProvider)); serviceManager.add(IPlotViewer, wrapType(PlotViewer)); serviceManager.addSingleton(IJupyterDebugger, wrapType(JupyterDebugger)); diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 637df00766f9..00b478b4f655 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -250,10 +250,22 @@ export interface INotebookEditor extends IInteractiveBase { closed: Event; executed: Event; modified: Event; + saved: Event; + /** + * Is this notebook representing an untitled file which has never been saved yet. + */ + readonly isUntitled: boolean; + /** + * `true` if there are unpersisted changes. + */ + readonly isDirty: boolean; readonly file: Uri; readonly visible: boolean; readonly active: boolean; load(contents: string, file: Uri): Promise; + runAllCells(): void; + runSelectedCell(): void; + addCellBelow(): void; } export const IInteractiveWindowListener = Symbol('IInteractiveWindowListener'); @@ -337,7 +349,6 @@ export interface ICell { file: string; line: number; state: CellState; - type: 'preview' | 'execute'; data: nbformat.ICodeCell | nbformat.IRawCell | nbformat.IMarkdownCell | IMessageCell; extraLines?: number[]; } @@ -347,6 +358,7 @@ export interface IInteractiveWindowInfo { undoCount: number; redoCount: number; visibleCells: ICell[]; + selectedCell: string | undefined; } export interface IMessageCell extends nbformat.IBaseCell { @@ -390,6 +402,11 @@ export interface IJupyterCommandFactory { } // Config settings we pass to our react code +export type FileSettings = { + autoSaveDelay: number; + autoSave: 'afterDelay' | 'off' | 'onFocusChange' | 'onWindowChange'; +}; + export interface IDataScienceExtraSettings extends IDataScienceSettings { extraSettings: { editorCursor: string; diff --git a/src/client/datascience/webViewHost.ts b/src/client/datascience/webViewHost.ts index 114e8fece340..a28255d4fec0 100644 --- a/src/client/datascience/webViewHost.ts +++ b/src/client/datascience/webViewHost.ts @@ -246,6 +246,8 @@ export class WebViewHost implements IDisposable { event.affectsConfiguration('editor.fontFamily') || event.affectsConfiguration('editor.cursorStyle') || event.affectsConfiguration('editor.cursorBlinking') || + event.affectsConfiguration('files.autoSave') || + event.affectsConfiguration('files.autoSaveDelay') || event.affectsConfiguration('python.dataScience.enableGather')) { // See if the theme changed const newSettings = this.generateDataScienceExtraSettings(); diff --git a/src/datascience-ui/history-react/interactiveCell.tsx b/src/datascience-ui/history-react/interactiveCell.tsx index e5ba31e99266..63cc46ac4cb8 100644 --- a/src/datascience-ui/history-react/interactiveCell.tsx +++ b/src/datascience-ui/history-react/interactiveCell.tsx @@ -4,6 +4,7 @@ import '../../client/common/extensions'; import { nbformat } from '@jupyterlab/coreutils'; +import * as fastDeepEqual from 'fast-deep-equal'; import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import * as React from 'react'; @@ -35,10 +36,6 @@ interface IInteractiveCellProps { editorOptions?: monacoEditor.editor.IEditorOptions; editExecutionCount?: string; editorMeasureClassName?: string; - selectedCell?: string; - focusedCell?: string; - hideOutput?: boolean; - showLineNumbers?: boolean; font: IFont; onCodeChange(changes: monacoEditor.editor.IModelContentChange[], cellId: string, modelId: string): void; onCodeCreated(code: string, file: string, cellId: string, modelId: string): void; @@ -65,18 +62,22 @@ export class InteractiveCell extends React.Component { public render() { if (this.props.cellVM.cell.data.cell_type === 'messages') { - return ; + return ; } else { return this.renderNormalCell(); } } public componentDidUpdate(prevProps: IInteractiveCellProps) { - if (this.props.selectedCell === this.props.cellVM.cell.id && prevProps.selectedCell !== this.props.selectedCell) { - this.giveFocus(this.props.focusedCell === this.props.cellVM.cell.id); + if (this.props.cellVM.selected && !prevProps.cellVM.selected) { + this.giveFocus(this.props.cellVM.focused); } } + public shouldComponentUpdate(nextProps: IInteractiveCellProps): boolean { + return !fastDeepEqual(this.props, nextProps); + } + public scrollAndFlash() { if (this.wrapperRef && this.wrapperRef.current) { this.wrapperRef.current.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' }); @@ -143,6 +144,7 @@ export class InteractiveCell extends React.Component { baseTheme={this.props.baseTheme} expandImage={this.props.expandImage} openLink={this.props.openLink} + maxTextSize={this.props.maxTextSize} /> @@ -211,7 +213,7 @@ export class InteractiveCell extends React.Component { focused={this.onCodeFocused} unfocused={this.onCodeUnfocused} keyDown={this.props.keyDown} - showLineNumbers={this.props.showLineNumbers} + showLineNumbers={this.props.cellVM.showLineNumbers} font={this.props.font} /> ); @@ -240,7 +242,7 @@ export class InteractiveCell extends React.Component { } private shouldRenderResults(): boolean { - return this.isCodeCell() && this.hasOutput() && this.getCodeCell().outputs && this.getCodeCell().outputs.length > 0 && !this.props.hideOutput; + return this.isCodeCell() && this.hasOutput() && this.getCodeCell().outputs && this.getCodeCell().outputs.length > 0 && !this.props.cellVM.hideOutput; } private onCellKeyDown = (event: React.KeyboardEvent) => { diff --git a/src/datascience-ui/history-react/interactivePanel.tsx b/src/datascience-ui/history-react/interactivePanel.tsx index 5038823a0730..46a0adf7c7d5 100644 --- a/src/datascience-ui/history-react/interactivePanel.tsx +++ b/src/datascience-ui/history-react/interactivePanel.tsx @@ -350,8 +350,6 @@ export class InteractivePanel extends React.Component diff --git a/src/datascience-ui/interactive-common/cellInput.tsx b/src/datascience-ui/interactive-common/cellInput.tsx index 3089235935f1..41ec875fcd1e 100644 --- a/src/datascience-ui/interactive-common/cellInput.tsx +++ b/src/datascience-ui/interactive-common/cellInput.tsx @@ -7,7 +7,7 @@ import { nbformat } from '@jupyterlab/coreutils'; import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import * as React from 'react'; -import { concatMultilineString } from '../../client/datascience/common'; +import { concatMultilineStringInput } from '../../client/datascience/common'; import { IKeyboardEvent } from '../react-common/event'; import { getLocString } from '../react-common/locReactSide'; import { Code } from './code'; @@ -26,7 +26,6 @@ interface ICellInputProps { monacoTheme: string | undefined; editorOptions?: monacoEditor.editor.IEditorOptions; editorMeasureClassName?: string; - focusedCell?: string; showLineNumbers?: boolean; font: IFont; onCodeChange(changes: monacoEditor.editor.IModelContentChange[], cellId: string, modelId: string): void; @@ -56,7 +55,7 @@ export class CellInput extends React.Component { } public componentDidUpdate(prevProps: ICellInputProps) { - if (this.props.focusedCell === this.props.cellVM.cell.id && prevProps.focusedCell !== this.props.focusedCell) { + if (this.props.cellVM.focused && !prevProps.cellVM.focused) { this.giveFocus(); } } @@ -140,7 +139,7 @@ export class CellInput extends React.Component { private renderMarkdownInputs = () => { if (this.shouldRenderMarkdownEditor()) { - const source = concatMultilineString(this.getMarkdownCell().source); + const source = concatMultilineStringInput(this.getMarkdownCell().source); return (
{ private renderMarkdownOutputs = () => { const markdown = this.getMarkdownCell(); // React-markdown expects that the source is a string - const source = concatMultilineString(markdown.source); + const source = concatMultilineStringInput(markdown.source); const Transform = transforms['text/markdown']; const MarkdownClassName = 'markdown-cell-output'; - return [
]; + return [
]; } // tslint:disable-next-line: max-func-body-length @@ -201,7 +201,7 @@ export class CellOutput extends React.Component { isError = false; renderWithScrollbars = true; const stream = copy as nbformat.IStream; - const formatted = concatMultilineString(stream.text); + const formatted = concatMultilineStringOutput(stream.text); copy.data = { 'text/html': formatted.includes('<') ? `${formatted}` : `
${formatted}
` }; @@ -251,10 +251,10 @@ export class CellOutput extends React.Component { case 'text/plain': return { mimeType, - data: concatMultilineString(data as nbformat.MultilineString), + data: concatMultilineStringOutput(data as nbformat.MultilineString), isText, isError, - renderWithScrollbars, + renderWithScrollbars: true, extraButton, doubleClick: noop }; diff --git a/src/datascience-ui/interactive-common/informationMessages.tsx b/src/datascience-ui/interactive-common/informationMessages.tsx index 71095624fab2..8ff13e47d225 100644 --- a/src/datascience-ui/interactive-common/informationMessages.tsx +++ b/src/datascience-ui/interactive-common/informationMessages.tsx @@ -8,7 +8,6 @@ import * as React from 'react'; interface IInformationMessagesProps { messages: string[]; - type: 'execute' | 'preview'; } export class InformationMessages extends React.Component { diff --git a/src/datascience-ui/interactive-common/mainState.ts b/src/datascience-ui/interactive-common/mainState.ts index 67bd7ed57ac4..71c237af66c8 100644 --- a/src/datascience-ui/interactive-common/mainState.ts +++ b/src/datascience-ui/interactive-common/mainState.ts @@ -9,7 +9,7 @@ import * as path from 'path'; import { IDataScienceSettings } from '../../client/common/types'; import { CellMatcher } from '../../client/datascience/cellMatcher'; -import { concatMultilineString, splitMultilineString } from '../../client/datascience/common'; +import { concatMultilineStringInput, splitMultilineString } from '../../client/datascience/common'; import { Identifiers } from '../../client/datascience/constants'; import { CellState, ICell, IJupyterVariable, IMessageCell } from '../../client/datascience/types'; import { noop } from '../../test/core'; @@ -26,6 +26,8 @@ export interface ICellViewModel { showLineNumbers?: boolean; hideOutput?: boolean; useQuickEdit?: boolean; + selected: boolean; + focused: boolean; inputBlockToggled(id: string): void; } @@ -53,11 +55,11 @@ export interface IMainState { pendingVariableCount: number; debugging: boolean; dirty?: boolean; - selectedCell?: string; - focusedCell?: string; + selectedCellId?: string; + focusedCellId?: string; enableGather: boolean; isAtBottom: boolean; - newCell?: string; + newCellId?: string; loadTotal?: number; } @@ -132,8 +134,7 @@ export function createEmptyCell(id: string | undefined, executionCount: number | id: id ? id : Identifiers.EditCellId, file: Identifiers.EmptyFileName, line: 0, - state: CellState.finished, - type: 'execute' + state: CellState.finished }; } @@ -145,7 +146,9 @@ export function createEditableCellVM(executionCount: number): ICellViewModel { inputBlockShow: true, inputBlockText: '', inputBlockCollapseNeeded: false, - inputBlockToggled: noop + inputBlockToggled: noop, + selected: false, + focused: false }; } @@ -169,7 +172,7 @@ export function extractInputText(inputCell: ICell, settings: IDataScienceSetting } } - return concatMultilineString(source); + return concatMultilineStringInput(source); } export function createCellVM(inputCell: ICell, settings: IDataScienceSettings | undefined, inputBlockToggled: (id: string) => void, editable: boolean): ICellViewModel { @@ -186,12 +189,14 @@ export function createCellVM(inputCell: ICell, settings: IDataScienceSettings | inputBlockShow: true, inputBlockText: inputText, inputBlockCollapseNeeded: (inputLinesCount > 1), - inputBlockToggled: inputBlockToggled + inputBlockToggled: inputBlockToggled, + selected: false, + focused: false }; } function generateVMs(inputBlockToggled: (id: string) => void, filePath: string, editable: boolean): ICellViewModel[] { - const cells = generateCells(filePath); + const cells = generateCells(filePath, 10); return cells.map((cell: ICell) => { const vm = createCellVM(cell, undefined, inputBlockToggled, editable); vm.useQuickEdit = false; @@ -199,16 +204,20 @@ function generateVMs(inputBlockToggled: (id: string) => void, filePath: string, }); } -function generateCells(filePath: string): ICell[] { +export function generateCells(filePath: string, repetitions: number): ICell[] { // Dupe a bunch times for perf reasons let cellData: (nbformat.ICodeCell | nbformat.IMarkdownCell | nbformat.IRawCell | IMessageCell)[] = []; - for (let i = 0; i < 10; i += 1) { + for (let i = 0; i < repetitions; i += 1) { cellData = [...cellData, ...generateCellData()]; } + // Dynamically require vscode, this is testing code. + // Obfuscate the import to prevent webpack from picking this up. + // tslint:disable-next-line: no-eval (webpack is smart enough to look for `require` and `eval`). + const Uri = eval('req' + 'uire')('vscode').Uri; return cellData.map((data: nbformat.ICodeCell | nbformat.IMarkdownCell | nbformat.IRawCell | IMessageCell, key: number) => { return { id: key.toString(), - file: path.join(filePath, 'foo.py'), + file: Uri.file(path.join(filePath, 'foo.py')).fsPath, line: 1, state: key === cellData.length - 1 ? CellState.executing : CellState.finished, type: key === 3 ? 'preview' : 'execute', @@ -454,7 +463,7 @@ function generateCellData(): (nbformat.ICodeCell | nbformat.IMarkdownCell | nbfo 'Nunc quis orci ante. Vivamus vel blandit velit.\n","Sed mattis dui diam, et blandit augue mattis vestibulum.\n', 'Suspendisse ornare interdum velit. Suspendisse potenti.\n', 'Morbi molestie lacinia sapien nec porttitor. Nam at vestibulum nisi.\n', - '\"\"\" ' + '\"\"\"' ] }, { diff --git a/src/datascience-ui/interactive-common/mainStateController.ts b/src/datascience-ui/interactive-common/mainStateController.ts index 361ca744b3ae..006a11ac8d67 100644 --- a/src/datascience-ui/interactive-common/mainStateController.ts +++ b/src/datascience-ui/interactive-common/mainStateController.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. 'use strict'; import * as fastDeepEqual from 'fast-deep-equal'; +import * as immutable from 'immutable'; import { min } from 'lodash'; // tslint:disable-next-line: no-require-imports import cloneDeep = require('lodash/cloneDeep'); @@ -10,7 +11,7 @@ import * as uuid from 'uuid/v4'; import { createDeferred, Deferred } from '../../client/common/utils/async'; import { CellMatcher } from '../../client/datascience/cellMatcher'; -import { concatMultilineString, generateMarkdownFromCodeLines } from '../../client/datascience/common'; +import { concatMultilineStringInput, generateMarkdownFromCodeLines } from '../../client/datascience/common'; import { Identifiers } from '../../client/datascience/constants'; import { IInteractiveWindowMapping, @@ -31,7 +32,14 @@ import { getSettings, updateSettings } from '../react-common/settingsReactSide'; import { detectBaseTheme } from '../react-common/themeDetector'; import { InputHistory } from './inputHistory'; import { IntellisenseProvider } from './intellisenseProvider'; -import { createCellVM, createEditableCellVM, extractInputText, generateTestState, ICellViewModel, IMainState } from './mainState'; +import { + createCellVM, + createEditableCellVM, + extractInputText, + generateTestState, + ICellViewModel, + IMainState +} from './mainState'; import { initializeTokenizer, registerMonacoLanguage } from './tokenizer'; export interface IMainStateControllerProps { @@ -48,9 +56,10 @@ export interface IMainStateControllerProps { // tslint:disable-next-line: max-func-body-length export class MainStateController implements IMessageHandler { + protected readonly postOffice: PostOffice = new PostOffice(); private stackLimit = 10; - private state: IMainState; - private postOffice: PostOffice = new PostOffice(); + private pendingState: IMainState; + private renderedState: IMainState; private intellisenseProvider: IntellisenseProvider; private onigasmPromise: Deferred | undefined; private tmlangugePromise: Deferred | undefined; @@ -60,7 +69,7 @@ export class MainStateController implements IMessageHandler { // tslint:disable-next-line:max-func-body-length constructor(private props: IMainStateControllerProps) { - this.state = { + this.renderedState = { editorOptions: this.computeEditorOptions(), cellVMs: [], busy: true, @@ -85,7 +94,7 @@ export class MainStateController implements IMessageHandler { // Add test state if necessary if (!this.props.skipDefault) { - this.state = generateTestState(this.inputBlockToggled, '', this.props.defaultEditable); + this.renderedState = generateTestState(this.inputBlockToggled, '', this.props.defaultEditable); } // Setup the completion provider for monaco. We only need one @@ -95,7 +104,7 @@ export class MainStateController implements IMessageHandler { if (this.props.skipDefault) { if (this.props.testMode) { // Running a test, skip the tokenizer. We want the UI to display synchronously - this.state = { tokenizerLoaded: true, ...this.state }; + this.renderedState = { tokenizerLoaded: true, ...this.renderedState }; // However we still need to register python as a language registerMonacoLanguage(); @@ -104,6 +113,9 @@ export class MainStateController implements IMessageHandler { } } + // Copy the rendered state + this.pendingState = { ...this.renderedState }; + // Add ourselves as a handler for the post office this.postOffice.addHandler(this); @@ -247,16 +259,16 @@ export class MainStateController implements IMessageHandler { } public stopBusy = () => { - if (this.state.busy) { + if (this.pendingState.busy) { this.setState({ busy: false }); } } public redo = () => { // Pop one off of our redo stack and update our undo - const cells = this.state.redoStack[this.state.redoStack.length - 1]; - const redoStack = this.state.redoStack.slice(0, this.state.redoStack.length - 1); - const undoStack = this.pushStack(this.state.undoStack, this.state.cellVMs); + const cells = this.pendingState.redoStack[this.pendingState.redoStack.length - 1]; + const redoStack = this.pendingState.redoStack.slice(0, this.pendingState.redoStack.length - 1); + const undoStack = this.pushStack(this.pendingState.undoStack, this.pendingState.cellVMs); this.sendMessage(InteractiveWindowMessages.Redo); this.setState({ cellVMs: cells, @@ -268,9 +280,9 @@ export class MainStateController implements IMessageHandler { public undo = () => { // Pop one off of our undo stack and update our redo - const cells = this.state.undoStack[this.state.undoStack.length - 1]; - const undoStack = this.state.undoStack.slice(0, this.state.undoStack.length - 1); - const redoStack = this.pushStack(this.state.redoStack, this.state.cellVMs); + const cells = this.pendingState.undoStack[this.pendingState.undoStack.length - 1]; + const undoStack = this.pendingState.undoStack.slice(0, this.pendingState.undoStack.length - 1); + const redoStack = this.pushStack(this.pendingState.redoStack, this.pendingState.cellVMs); this.sendMessage(InteractiveWindowMessages.Undo); this.setState({ cellVMs: cells, @@ -281,15 +293,30 @@ export class MainStateController implements IMessageHandler { } public deleteCell = (cellId: string) => { - const cellVM = this.state.cellVMs.find(c => c.cell.id === cellId); - if (cellVM) { + const index = this.findCellIndex(cellId); + if (index >= 0) { this.sendMessage(InteractiveWindowMessages.DeleteCell); - this.sendMessage(InteractiveWindowMessages.RemoveCell, { id: cellVM.cell.id }); + this.sendMessage(InteractiveWindowMessages.RemoveCell, { id: cellId }); + + // Recompute select/focus if this item has either + let newSelection = this.pendingState.selectedCellId; + let newFocused = this.pendingState.focusedCellId; + const newVMs = [...this.pendingState.cellVMs.filter(c => c.cell.id !== cellId)]; + const nextOrPrev = index === this.pendingState.cellVMs.length - 1 ? index - 1 : index; + if (this.pendingState.selectedCellId === cellId || this.pendingState.focusedCellId === cellId) { + if (nextOrPrev >= 0) { + newVMs[nextOrPrev] = { ...newVMs[nextOrPrev], selected: true, focused: this.pendingState.focusedCellId === cellId }; + newSelection = newVMs[nextOrPrev].cell.id; + newFocused = newVMs[nextOrPrev].focused ? newVMs[nextOrPrev].cell.id : undefined; + } + } // Update our state this.setState({ - cellVMs: this.state.cellVMs.filter(c => c.cell.id !== cellId), - undoStack: this.pushStack(this.state.undoStack, this.state.cellVMs), + cellVMs: newVMs, + selectedCellId: newSelection, + focusedCellId: newFocused, + undoStack: this.pushStack(this.pendingState.undoStack, this.pendingState.cellVMs), skipNextScroll: true }); } @@ -326,7 +353,7 @@ export class MainStateController implements IMessageHandler { public save = () => { // We have to take the current value of each cell to make sure we have the correct text. - this.state.cellVMs.forEach(c => this.updateCellSource(c.cell.id)); + this.pendingState.cellVMs.forEach(c => this.updateCellSource(c.cell.id)); // Then send the save with the new state. this.sendMessage(InteractiveWindowMessages.SaveAll, { cells: this.getNonEditCellVMs().map(cvm => cvm.cell) }); @@ -357,11 +384,11 @@ export class MainStateController implements IMessageHandler { } public canRedo = () => { - return this.state.redoStack.length > 0; + return this.pendingState.redoStack.length > 0; } public canUndo = () => { - return this.state.undoStack.length > 0; + return this.pendingState.undoStack.length > 0; } public canClearAllOutputs = () => { @@ -369,10 +396,8 @@ export class MainStateController implements IMessageHandler { } public clearAllOutputs = () => { - const newList = this.state.cellVMs.map(cellVM => { - const newVM = cloneDeep(cellVM); - newVM.cell.data.outputs = []; - return newVM; + const newList = this.pendingState.cellVMs.map(cellVM => { + return immutable.updateIn(cellVM, ['cell', 'data', 'outputs'], () => []); }); this.setState({ cellVMs: newList @@ -381,7 +406,7 @@ export class MainStateController implements IMessageHandler { public gotoCellCode = (cellId: string) => { // Find our cell - const cellVM = this.state.cellVMs.find(c => c.cell.id === cellId); + const cellVM = this.pendingState.cellVMs.find(c => c.cell.id === cellId); // Send a message to the other side to jump to a particular cell if (cellVM) { @@ -391,9 +416,9 @@ export class MainStateController implements IMessageHandler { public copyCellCode = (cellId: string) => { // Find our cell. This is also supported on the edit cell - let cellVM = this.state.cellVMs.find(c => c.cell.id === cellId); - if (!cellVM && this.state.editCellVM && cellId === this.state.editCellVM.cell.id) { - cellVM = this.state.editCellVM; + let cellVM = this.pendingState.cellVMs.find(c => c.cell.id === cellId); + if (!cellVM && this.pendingState.editCellVM && cellId === this.pendingState.editCellVM.cell.id) { + cellVM = this.pendingState.editCellVM; } // Send a message to the other side to jump to a particular cell @@ -420,19 +445,19 @@ export class MainStateController implements IMessageHandler { public export = () => { // Send a message to the other side to export our current list - const cellContents: ICell[] = this.state.cellVMs.map((cellVM: ICellViewModel, _index: number) => { return cellVM.cell; }); + const cellContents: ICell[] = this.pendingState.cellVMs.map((cellVM: ICellViewModel, _index: number) => { return cellVM.cell; }); this.sendMessage(InteractiveWindowMessages.Export, cellContents); } // When the variable explorer wants to refresh state (say if it was expanded) public refreshVariables = (newExecutionCount?: number) => { - this.sendMessage(InteractiveWindowMessages.GetVariablesRequest, newExecutionCount === undefined ? this.state.currentExecutionCount : newExecutionCount); + this.sendMessage(InteractiveWindowMessages.GetVariablesRequest, newExecutionCount === undefined ? this.pendingState.currentExecutionCount : newExecutionCount); } public toggleVariableExplorer = () => { - this.sendMessage(InteractiveWindowMessages.VariableExplorerToggle, !this.state.variablesVisible); - this.setState({ variablesVisible: !this.state.variablesVisible }); - if (!this.state.variablesVisible) { + this.sendMessage(InteractiveWindowMessages.VariableExplorerToggle, !this.pendingState.variablesVisible); + this.setState({ variablesVisible: !this.pendingState.variablesVisible }); + if (this.pendingState.variablesVisible) { this.refreshVariables(); } } @@ -452,7 +477,7 @@ export class MainStateController implements IMessageHandler { } public readOnlyCodeCreated = (_text: string, file: string, id: string, monacoId: string) => { - const cell = this.state.cellVMs.find(c => c.cell.id === id); + const cell = this.pendingState.cellVMs.find(c => c.cell.id === id); if (cell) { // Pass this onto the completion provider running in the extension this.sendMessage(InteractiveWindowMessages.AddCell, { @@ -476,28 +501,71 @@ export class MainStateController implements IMessageHandler { public codeLostFocus = (cellId: string) => { this.onCodeLostFocus(cellId); - if (this.state.focusedCell === cellId) { + if (this.pendingState.focusedCellId === cellId) { + const newVMs = [...this.pendingState.cellVMs]; + // Switch the old vm + const oldSelect = this.findCellIndex(cellId); + if (oldSelect >= 0) { + newVMs[oldSelect] = { ...newVMs[oldSelect], focused: false }; + } // Only unfocus if we haven't switched somewhere else yet - this.setState({ focusedCell: undefined }); + this.setState({ focusedCellId: undefined, cellVMs: newVMs }); } } public codeGotFocus = (cellId: string | undefined) => { - this.setState({ selectedCell: cellId, focusedCell: cellId }); + // Skip if already has focus + if (cellId !== this.pendingState.focusedCellId) { + const newVMs = [...this.pendingState.cellVMs]; + // Reset the old vms (nothing should be selected/focused) + // Change state only for cells that were selected/focused + newVMs.forEach((cellVM, index) => { + if (cellVM.selected || cellVM.focused) { + newVMs[index] = { ...cellVM, selected: false, focused: false }; + } + }); + const newSelect = this.findCellIndex(cellId); + if (newSelect >= 0) { + newVMs[newSelect] = { ...newVMs[newSelect], selected: true, focused: true }; + } + + // Save the whole thing in our state. + this.setState({ selectedCellId: cellId, focusedCellId: cellId, cellVMs: newVMs }); + } } - public selectCell = (cellId: string, focusedCell?: string) => { - this.setState({ selectedCell: cellId, focusedCell }); + public selectCell = (cellId: string, focusedCellId?: string) => { + // Skip if already the same cell + if (this.pendingState.selectedCellId !== cellId || this.pendingState.focusedCellId !== focusedCellId) { + const newVMs = [...this.pendingState.cellVMs]; + // Reset the old vms (nothing should be selected/focused) + // Change state only for cells that were selected/focused + newVMs.forEach((cellVM, index) => { + if (cellVM.selected || cellVM.focused) { + newVMs[index] = { ...cellVM, selected: false, focused: false }; + } + }); + const newSelect = this.findCellIndex(cellId); + if (newSelect >= 0) { + newVMs[newSelect] = { ...newVMs[newSelect], selected: true, focused: focusedCellId === newVMs[newSelect].cell.id }; + } + + // Save the whole thing in our state. + this.setState({ selectedCellId: cellId, focusedCellId, cellVMs: newVMs }); + } } public changeCellType = (cellId: string, newType: 'code' | 'markdown') => { - const index = this.state.cellVMs.findIndex(c => c.cell.id === cellId); - if (index >= 0 && this.state.cellVMs[index].cell.data.cell_type !== newType) { - const newVM = cloneDeep(this.state.cellVMs[index]); - newVM.cell.data.cell_type = newType; - const cellVMs = [...this.state.cellVMs]; - cellVMs.splice(index, 1, newVM); + const index = this.pendingState.cellVMs.findIndex(c => c.cell.id === cellId); + if (index >= 0 && this.pendingState.cellVMs[index].cell.data.cell_type !== newType) { + const cellVMs = [...this.pendingState.cellVMs]; + cellVMs[index] = immutable.updateIn(this.pendingState.cellVMs[index], ['cell', 'data', 'cell_type'], () => newType); this.setState({ cellVMs }); + if (newType === 'code') { + this.sendMessage(InteractiveWindowMessages.InsertCell, { id: cellId, code: concatMultilineStringInput(cellVMs[index].cell.data.source), codeCellAbove: this.firstCodeCellAbove(cellId) }); + } else { + this.sendMessage(InteractiveWindowMessages.RemoveCell, { id: cellId }); + } } } @@ -552,9 +620,9 @@ export class MainStateController implements IMessageHandler { // Stick in a new cell at the bottom that's editable and update our state // so that the last cell becomes busy this.setState({ - cellVMs: [...this.state.cellVMs, newCell], - undoStack: this.pushStack(this.state.undoStack, this.state.cellVMs), - redoStack: this.state.redoStack, + cellVMs: [...this.pendingState.cellVMs, newCell], + undoStack: this.pushStack(this.pendingState.undoStack, this.pendingState.cellVMs), + redoStack: this.pendingState.redoStack, skipNextScroll: false, submittedText: true }); @@ -564,74 +632,79 @@ export class MainStateController implements IMessageHandler { this.sendMessage(InteractiveWindowMessages.SubmitNewCell, { code, id: newCell.cell.id }); } } else if (inputCell.cell.data.cell_type === 'code') { - // Update our input cell to be in progress again - inputCell.cell.state = CellState.executing; - - // Clear our outputs - inputCell.cell.data.outputs = []; - - // Update our state to display the new status - this.setState({ - cellVMs: [...this.state.cellVMs] - }); + const index = this.findCellIndex(inputCell.cell.id); + if (index >= 0) { + // Update our input cell to be in progress again and clear outputs + const newVMs = [...this.pendingState.cellVMs]; + newVMs[index] = { ...inputCell, cell: { ...inputCell.cell, state: CellState.executing, data: { ...inputCell.cell.data, outputs: [] } } }; + this.setState({ + cellVMs: newVMs + }); + } // Send a message to rexecute this code this.sendMessage(InteractiveWindowMessages.ReExecuteCell, { code, id: inputCell.cell.id }); } else if (inputCell.cell.data.cell_type === 'markdown') { - // Change the input on the cell - inputCell.cell.data.source = code; - inputCell.inputBlockText = code; - - // Update our state to display the new status - this.setState({ - cellVMs: [...this.state.cellVMs] - }); + const index = this.findCellIndex(inputCell.cell.id); + if (index >= 0) { + // Change the input on the cell + const newVMs = [...this.pendingState.cellVMs]; + newVMs[index] = { ...inputCell, inputBlockText: code, cell: { ...inputCell.cell, data: { ...inputCell.cell.data, source: code } } }; + this.setState({ + cellVMs: newVMs + }); + } } } public findCell(cellId?: string): ICellViewModel | undefined { - const nonEdit = this.state.cellVMs.find(cvm => cvm.cell.id === cellId); + const nonEdit = this.pendingState.cellVMs.find(cvm => cvm.cell.id === cellId); if (!nonEdit && cellId === Identifiers.EditCellId) { - return this.state.editCellVM; + return this.pendingState.editCellVM; } return nonEdit; } + public findCellIndex(cellId?: string): number { + return this.pendingState.cellVMs.findIndex(cvm => cvm.cell.id === cellId); + } + public getMonacoId(cellId: string): string | undefined { return this.cellIdToMonacoId.get(cellId); } public toggleLineNumbers = (cellId: string) => { - const index = this.state.cellVMs.findIndex(c => c.cell.id === cellId); + const index = this.pendingState.cellVMs.findIndex(c => c.cell.id === cellId); if (index >= 0) { - const newVMs = [...this.state.cellVMs]; - newVMs[index] = cloneDeep(newVMs[index]); - newVMs[index].showLineNumbers = !newVMs[index].showLineNumbers; + const newVMs = [...this.pendingState.cellVMs]; + newVMs[index] = immutable.merge(newVMs[index], { showLineNumbers: !newVMs[index].showLineNumbers }); this.setState({ cellVMs: newVMs }); } } public toggleOutput = (cellId: string) => { - const index = this.state.cellVMs.findIndex(c => c.cell.id === cellId); + const index = this.pendingState.cellVMs.findIndex(c => c.cell.id === cellId); if (index >= 0) { - const newVMs = [...this.state.cellVMs]; - newVMs[index] = cloneDeep(newVMs[index]); - newVMs[index].hideOutput = !newVMs[index].hideOutput; + const newVMs = [...this.pendingState.cellVMs]; + newVMs[index] = immutable.merge(newVMs[index], { hideOutput: !newVMs[index].hideOutput }); this.setState({ cellVMs: newVMs }); } } public setState(newState: {}, callback?: () => void) { + // Add to writable state (it should always reflect the current conditions) + this.pendingState = { ...this.pendingState, ...newState }; + if (this.suspendUpdateCount > 0) { // Just save our new state - this.state = { ...this.state, ...newState }; + this.renderedState = { ...this.renderedState, ...newState }; if (callback) { callback(); } } else { // Send a UI update this.props.setState(newState, () => { - this.state = { ...this.state, ...newState }; + this.renderedState = { ...this.renderedState, ...newState }; if (callback) { callback(); } @@ -640,22 +713,29 @@ export class MainStateController implements IMessageHandler { } public renderUpdate(newState: {}) { + const oldCount = this.renderedState.pendingVariableCount; + // This method should be called during the render stage of anything // using this state Controller. That's because after shouldComponentUpdate // render is next and at this point the state has been set. // See https://reactjs.org/docs/react-component.html // Otherwise we set the state in the callback during setState and this can be // too late for any render code to use the stateController. - this.state = { ...this.state, ...newState }; + this.renderedState = { ...this.renderedState, ...newState }; // If the new state includes any cellVM changes, send an update to the other side if ('cellVMs' in newState) { this.sendInfo(); } + + // If the new state includes pendingVariableCount and it's gone to zero, send a message + if (this.renderedState.pendingVariableCount === 0 && oldCount !== 0) { + setTimeout(() => this.sendMessage(InteractiveWindowMessages.VariablesComplete), 1); + } } public getState(): IMainState { - return this.state; + return this.pendingState; } // Adjust the visibility or collapsed state of a cell @@ -723,37 +803,38 @@ export class MainStateController implements IMessageHandler { this.insertCell(cell); } - protected insertCell(cell: ICell, position?: number, isMonaco?: boolean): ICellViewModel | undefined { - if (cell) { - const showInputs = getSettings().showCellInputCode; - const collapseInputs = getSettings().collapseCellInputCodeByDefault; - let cellVM: ICellViewModel = createCellVM(cell, getSettings(), this.inputBlockToggled, this.props.defaultEditable); + protected prepareCellVM(cell: ICell, isMonaco?: boolean): ICellViewModel { + const showInputs = getSettings().showCellInputCode; + const collapseInputs = getSettings().collapseCellInputCodeByDefault; + let cellVM: ICellViewModel = createCellVM(cell, getSettings(), this.inputBlockToggled, this.props.defaultEditable); - // Set initial cell visibility and collapse - cellVM = this.alterCellVM(cellVM, showInputs, !collapseInputs); + // Set initial cell visibility and collapse + cellVM = this.alterCellVM(cellVM, showInputs, !collapseInputs); - if (cellVM) { - if (isMonaco) { - cellVM.useQuickEdit = false; - } + if (isMonaco) { + cellVM.useQuickEdit = false; + } - const newList = [...this.state.cellVMs]; - // Make sure to use the same array so our entire state doesn't update - if (position !== undefined && position >= 0) { - newList.splice(position, 0, cellVM); - } else { - newList.push(cellVM); - } - this.setState({ - cellVMs: newList, - undoStack: this.pushStack(this.state.undoStack, this.state.cellVMs), - redoStack: this.state.redoStack, - skipNextScroll: false - }); + return cellVM; + } - return cellVM; - } + protected insertCell(cell: ICell, position?: number, isMonaco?: boolean): ICellViewModel { + const cellVM = this.prepareCellVM(cell, isMonaco); + const newList = [...this.pendingState.cellVMs]; + // Make sure to use the same array so our entire state doesn't update + if (position !== undefined && position >= 0) { + newList.splice(position, 0, cellVM); + } else { + newList.push(cellVM); } + this.setState({ + cellVMs: newList, + undoStack: this.pushStack(this.pendingState.undoStack, this.pendingState.cellVMs), + redoStack: this.pendingState.redoStack, + skipNextScroll: false + }); + + return cellVM; } protected suspendUpdates() { @@ -764,7 +845,7 @@ export class MainStateController implements IMessageHandler { if (this.suspendUpdateCount > 0) { this.suspendUpdateCount -= 1; if (this.suspendUpdateCount === 0) { - this.setState(this.state); // This should cause an update + this.setState(this.pendingState); // This should cause an update } } @@ -783,6 +864,15 @@ export class MainStateController implements IMessageHandler { return [...slicedUndo, copy]; } + protected firstCodeCellAbove(cellId: string): string | undefined { + const codeCells = this.pendingState.cellVMs.filter(c => c.cell.data.cell_type === 'code'); + const index = codeCells.findIndex(c => c.cell.id === cellId); + if (index > 0) { + return codeCells[index - 1].cell.id; + } + return undefined; + } + private computeEditorOptions(): monacoEditor.editor.IEditorOptions { const intellisenseOptions = getSettings().intellisenseOptions; const extraSettings = getSettings().extraSettings; @@ -819,12 +909,12 @@ export class MainStateController implements IMessageHandler { // Turn off updates so we generate all of the cell vms without rendering. this.suspendUpdates(); - // Update all of the vms + // Generate all of the VMs const cells = payload.cells as ICell[]; - cells.forEach(c => this.finishCell(c)); + const vms = cells.map(c => this.prepareCellVM(c, true)); // Set our state to not being busy anymore. Clear undo stack as this can't be undone. - this.setState({ busy: false, loadTotal: payload.cells.length, undoStack: [] }); + this.setState({ busy: false, loadTotal: payload.cells.length, undoStack: [], cellVMs: vms }); // Turn updates back on and resend the state. this.resumeUpdates(); @@ -835,15 +925,14 @@ export class MainStateController implements IMessageHandler { this.suspendUpdates(); // When we restart, make sure to turn off all executing cells. They aren't executing anymore - const executingCells = this.state.cellVMs + const executingCells = this.pendingState.cellVMs .map((cvm, i) => { return { cvm, i }; }) .filter(s => s.cvm.cell.state !== CellState.error && s.cvm.cell.state !== CellState.finished); if (executingCells && executingCells.length) { - const newVMs = [...this.state.cellVMs]; + const newVMs = [...this.pendingState.cellVMs]; executingCells.forEach(s => { - newVMs[s.i] = cloneDeep(s.cvm); - newVMs[s.i].cell.state = CellState.finished; + newVMs[s.i] = immutable.updateIn(s.cvm, ['cell', 'state'], () => CellState.finished); }); this.setState({ cellVMs: newVMs }); } @@ -895,7 +984,7 @@ export class MainStateController implements IMessageHandler { // Update theme if necessary const newSettings = JSON.parse(payload as string); const dsSettings = newSettings as IDataScienceExtraSettings; - if (dsSettings && dsSettings.extraSettings && dsSettings.extraSettings.theme !== this.state.theme) { + if (dsSettings && dsSettings.extraSettings && dsSettings.extraSettings.theme !== this.pendingState.theme) { // User changed the current theme. Rerender this.postOffice.sendUnsafeMessage(CssMessages.GetCssRequest, { isDark: this.computeKnownDark() }); this.postOffice.sendUnsafeMessage(CssMessages.GetMonacoThemeRequest, { isDark: this.computeKnownDark() }); @@ -921,7 +1010,7 @@ export class MainStateController implements IMessageHandler { private getAllCells = () => { // Send all of our cells back to the other side - const cells = this.state.cellVMs.map((cellVM: ICellViewModel) => { + const cells = this.pendingState.cellVMs.map((cellVM: ICellViewModel) => { return cellVM.cell; }); @@ -929,14 +1018,14 @@ export class MainStateController implements IMessageHandler { } private getNonEditCellVMs(): ICellViewModel[] { - return this.state.cellVMs; + return this.pendingState.cellVMs; } private clearAllSilent = () => { // Update our state this.setState({ cellVMs: [], - undoStack: this.pushStack(this.state.undoStack, this.state.cellVMs), + undoStack: this.pushStack(this.pendingState.undoStack, this.pendingState.cellVMs), skipNextScroll: true, busy: false // No more progress on delete all }); @@ -944,7 +1033,7 @@ export class MainStateController implements IMessageHandler { private inputBlockToggled = (id: string) => { // Create a shallow copy of the array, let not const as this is the shallow array copy that we will be changing - const cellVMArray: ICellViewModel[] = [...this.state.cellVMs]; + const cellVMArray: ICellViewModel[] = [...this.pendingState.cellVMs]; const cellVMIndex = cellVMArray.findIndex((value: ICellViewModel) => { return value.cell.id === id; }); @@ -980,7 +1069,7 @@ export class MainStateController implements IMessageHandler { } private alterAllCellVMs = (visible: boolean, expanded: boolean) => { - const newCells = this.state.cellVMs.map((value: ICellViewModel) => { + const newCells = this.pendingState.cellVMs.map((value: ICellViewModel) => { return this.alterCellVM(value, visible, expanded); }); @@ -994,14 +1083,15 @@ export class MainStateController implements IMessageHandler { const info: IInteractiveWindowInfo = { visibleCells: this.getNonEditCellVMs().map(cvm => cvm.cell), cellCount: this.getNonEditCellVMs().length, - undoCount: this.state.undoStack.length, - redoCount: this.state.redoStack.length + undoCount: this.pendingState.undoStack.length, + redoCount: this.pendingState.redoStack.length, + selectedCell: this.pendingState.selectedCellId }; this.sendMessage(InteractiveWindowMessages.SendInfo, info); } private updateOrAdd = (cell: ICell, allowAdd?: boolean) => { - const index = this.state.cellVMs.findIndex((c: ICellViewModel) => { + const index = this.pendingState.cellVMs.findIndex((c: ICellViewModel) => { return c.cell.id === cell.id && c.cell.line === cell.line && c.cell.file === cell.file; @@ -1010,9 +1100,9 @@ export class MainStateController implements IMessageHandler { // This means the cell existed already so it was actual executed code. // Use its execution count to update our execution count. const newExecutionCount = cell.data.execution_count ? - Math.max(this.state.currentExecutionCount, parseInt(cell.data.execution_count.toString(), 10)) : - this.state.currentExecutionCount; - if (newExecutionCount !== this.state.currentExecutionCount && this.state.variablesVisible) { + Math.max(this.pendingState.currentExecutionCount, parseInt(cell.data.execution_count.toString(), 10)) : + this.pendingState.currentExecutionCount; + if (newExecutionCount !== this.pendingState.currentExecutionCount && this.pendingState.variablesVisible) { // We also need to update our variable explorer when the execution count changes // Use the ref here to maintain var explorer independence this.refreshVariables(newExecutionCount); @@ -1020,17 +1110,31 @@ export class MainStateController implements IMessageHandler { // Have to make a copy of the cell VM array or // we won't actually update. - const newVMs = [...this.state.cellVMs]; - newVMs[index] = cloneDeep(newVMs[index]); + const newVMs = [...this.pendingState.cellVMs]; + // Live share has been disabled for now, see https://github.com/microsoft/vscode-python/issues/7972 // Check to see if our code still matches for the cell (in liveshare it might be updated from the other side) - if (concatMultilineString(newVMs[index].cell.data.source) !== concatMultilineString(cell.data.source)) { - const newText = extractInputText(cell, getSettings()); - newVMs[index].inputBlockText = newText; + // if (concatMultilineStringInput(this.pendingState.cellVMs[index].cell.data.source) !== concatMultilineStringInput(cell.data.source)) { + + // If cell state changes, then update just the state and the cell data (excluding source). + // Prevent updates to the source, as its possible we have recieved a response for a cell execution + // and the user has updated the cell text since then. + if (this.pendingState.cellVMs[index].cell.state !== cell.state) { + newVMs[index] = { + ...newVMs[index], + cell: { + ...newVMs[index].cell, + state: cell.state, + data: { + ...cell.data, + source: newVMs[index].cell.data.source + } + } + }; + } else { + newVMs[index] = { ...newVMs[index], cell: cell }; } - newVMs[index].cell = cell; - this.setState({ cellVMs: newVMs, currentExecutionCount: newExecutionCount @@ -1088,14 +1192,14 @@ export class MainStateController implements IMessageHandler { const variable = payload as IJupyterVariable; // Only send the updated variable data if we are on the same execution count as when we requested it - if (variable && variable.executionCount !== undefined && variable.executionCount === this.state.currentExecutionCount) { - const stateVariable = this.state.variables.findIndex(v => v.name === variable.name); + if (variable && variable.executionCount !== undefined && variable.executionCount === this.pendingState.currentExecutionCount) { + const stateVariable = this.pendingState.variables.findIndex(v => v.name === variable.name); if (stateVariable >= 0) { - const newState = [...this.state.variables]; + const newState = [...this.pendingState.variables]; newState.splice(stateVariable, 1, variable); this.setState({ variables: newState, - pendingVariableCount: Math.max(0, this.state.pendingVariableCount - 1) + pendingVariableCount: Math.max(0, this.pendingState.pendingVariableCount - 1) }); } } @@ -1109,7 +1213,7 @@ export class MainStateController implements IMessageHandler { const variablesResponse = payload as IJupyterVariablesResponse; // Check to see if we have moved to a new execution count only send our update if we are on the same count as the request - if (variablesResponse.executionCount === this.state.currentExecutionCount) { + if (variablesResponse.executionCount === this.pendingState.currentExecutionCount) { this.setState({ variables: variablesResponse.variables, pendingVariableCount: variablesResponse.variables.length @@ -1175,7 +1279,7 @@ export class MainStateController implements IMessageHandler { // We also get this in our response, but computing is more reliable // than searching for it. - if (this.state.knownDark !== computedKnownDark) { + if (this.pendingState.knownDark !== computedKnownDark) { this.darkChanged(computedKnownDark); } diff --git a/src/datascience-ui/interactive-common/markdown.tsx b/src/datascience-ui/interactive-common/markdown.tsx index 708a0c920ff7..7e7f5daf617f 100644 --- a/src/datascience-ui/interactive-common/markdown.tsx +++ b/src/datascience-ui/interactive-common/markdown.tsx @@ -36,7 +36,10 @@ export class Markdown extends React.Component { } public render() { + const classes = 'markdown-editor-area'; + return ( +
{ useQuickEdit={this.props.useQuickEdit} font={this.props.font} /> +
); } diff --git a/src/datascience-ui/native-editor/nativeCell.tsx b/src/datascience-ui/native-editor/nativeCell.tsx index eed7222c1cc4..774615f886bb 100644 --- a/src/datascience-ui/native-editor/nativeCell.tsx +++ b/src/datascience-ui/native-editor/nativeCell.tsx @@ -4,9 +4,10 @@ import '../../client/common/extensions'; import { nbformat } from '@jupyterlab/coreutils'; +import * as fastDeepEqual from 'fast-deep-equal'; import * as React from 'react'; -import { concatMultilineString } from '../../client/datascience/common'; +import { concatMultilineStringInput } from '../../client/datascience/common'; import { Identifiers } from '../../client/datascience/constants'; import { NativeCommandType } from '../../client/datascience/interactive-common/interactiveWindowTypes'; import { CellState, ICell } from '../../client/datascience/types'; @@ -32,20 +33,14 @@ interface INativeCellProps { maxTextSize?: number; stateController: NativeEditorStateController; monacoTheme: string | undefined; - hideOutput?: boolean; - showLineNumbers?: boolean; - selectedCell?: string; - focusedCell?: string; + lastCell: boolean; font: IFont; focusCell(cellId: string, focusCode: boolean): void; selectCell(cellId: string): void; } -interface INativeCellState { - showingMarkdownEditor: boolean; -} // tslint:disable: react-this-binding-issue -export class NativeCell extends React.Component { +export class NativeCell extends React.Component { private inputRef: React.RefObject = React.createRef(); private wrapperRef: React.RefObject = React.createRef(); private lastKeyPressed: string | undefined; @@ -53,26 +48,29 @@ export class NativeCell extends React.Component; + return ; } else { return this.renderNormalCell(); } } public componentDidUpdate(prevProps: INativeCellProps) { - if (this.props.selectedCell === this.props.cellVM.cell.id && prevProps.selectedCell !== this.props.selectedCell) { - this.giveFocus(this.props.focusedCell === this.props.cellVM.cell.id); + if (this.props.cellVM.selected && !prevProps.cellVM.selected) { + this.giveFocus(this.props.cellVM.focused); } // Anytime we update, reset the key. This object will be reused for different cell ids this.lastKeyPressed = undefined; } + public shouldComponentUpdate(nextProps: INativeCellProps): boolean { + return !fastDeepEqual(this.props, nextProps); + } + public giveFocus(giveCodeFocus: boolean) { // Start out with ourselves if (this.wrapperRef && this.wrapperRef.current) { @@ -83,9 +81,6 @@ export class NativeCell extends React.Component { - return this.props.selectedCell === this.cellId; + return this.props.cellVM.selected; } private isFocused = () => { - return this.props.focusedCell === this.cellId; + return this.props.cellVM.focused; } private renderNormalCell() { @@ -149,7 +144,7 @@ export class NativeCell extends React.Component
{this.renderCollapseBar(false)} @@ -185,11 +180,16 @@ export class NativeCell extends React.Component) => { - // When we receive a click, propagate upwards. Might change our state - ev.stopPropagation(); - this.lastKeyPressed = undefined; - const focusedCell = this.isFocused() ? this.cellId : undefined; - this.props.stateController.selectCell(this.cellId, focusedCell); + if (ev.nativeEvent.target) { + const elem = ev.nativeEvent.target as HTMLElement; + if (!elem.className.includes('image-button')) { + // Not a click on an button in a toolbar, select the cell. + ev.stopPropagation(); + this.lastKeyPressed = undefined; + const focusedCellId = this.isFocused() ? this.cellId : undefined; + this.props.stateController.selectCell(this.cellId, focusedCellId); + } + } } private onMouseDoubleClick = (ev: React.MouseEvent) => { @@ -203,7 +203,11 @@ export class NativeCell extends React.Component { - return (this.isMarkdownCell() && (this.state.showingMarkdownEditor || this.props.cellVM.cell.id === Identifiers.EditCellId)); + return (this.isMarkdownCell() && (this.isShowingMarkdownEditor() || this.props.cellVM.cell.id === Identifiers.EditCellId)); + } + + private isShowingMarkdownEditor = (): boolean => { + return (this.isMarkdownCell() && this.props.cellVM.focused); } private shouldRenderInput(): boolean { @@ -221,9 +225,9 @@ export class NativeCell extends React.Component @@ -545,14 +553,8 @@ export class NativeCell extends React.Component { const cellId = this.props.cellVM.cell.id; const deleteCell = () => { - const cellToSelect = this.getNextCellId() || this.getPrevCellId(); this.props.stateController.possiblyDeleteCell(cellId); this.props.stateController.sendCommand(NativeCommandType.DeleteCell, 'mouse'); - setTimeout(() => { - if (cellToSelect) { - this.moveSelection(cellToSelect); - } - }, 10); }; const runAbove = () => { this.props.stateController.runAbove(cellId); @@ -569,7 +571,7 @@ export class NativeCell extends React.Component { this.props.stateController.changeCellType(cellId, 'markdown'); this.props.stateController.sendCommand(NativeCommandType.ChangeToMarkdown, 'mouse'); - setTimeout(() => this.props.focusCell(cellId, true), 10); + setTimeout(() => this.props.focusCell(cellId, true), 0); }; const switchToCode = () => { const handler = () => { @@ -577,12 +579,12 @@ export class NativeCell extends React.Component { this.props.stateController.updateCellSource(cellId); - this.runAndMove(concatMultilineString(this.props.cellVM.cell.data.source)); + this.runAndMove(concatMultilineStringInput(this.props.cellVM.cell.data.source)); this.props.stateController.sendCommand(NativeCommandType.Run, 'mouse'); }; const canRunBelow = this.props.cellVM.cell.state === CellState.finished || this.props.cellVM.cell.state === CellState.error; @@ -657,7 +659,7 @@ export class NativeCell extends React.Component ); @@ -678,8 +680,6 @@ export class NativeCell extends React.Component { - this.props.stateController.codeLostFocus(this.cellId); - // There might be a pending focus loss handler. if (this.pendingFocusLoss) { const func = this.pendingFocusLoss; @@ -687,10 +687,7 @@ export class NativeCell extends React.Component { @@ -701,6 +698,7 @@ export class NativeCell extends React.Component ); } @@ -728,10 +726,10 @@ export class NativeCell extends React.Component { let classes = 'collapse-bar'; - if (this.props.selectedCell === this.props.cellVM.cell.id && this.props.focusedCell !== this.props.cellVM.cell.id) { + if (this.isSelected() && !this.isFocused()) { classes += ' collapse-bar-selected'; } - if (this.props.focusedCell === this.props.cellVM.cell.id) { + if (this.isFocused()) { classes += ' collapse-bar-focused'; } diff --git a/src/datascience-ui/native-editor/nativeEditor.less b/src/datascience-ui/native-editor/nativeEditor.less index a5a3f8d8e96c..80c7bbb55ea3 100644 --- a/src/datascience-ui/native-editor/nativeEditor.less +++ b/src/datascience-ui/native-editor/nativeEditor.less @@ -115,6 +115,16 @@ background: var(--override-widget-background, var(--vscode-notifications-background)); } +.markdown-editor-area { + position: relative; + width:100%; + padding-right: 10px; + margin-bottom: 0px; + padding-left: 2px; + padding-top: 2px; + background: var(--override-widget-background, var(--vscode-notifications-background)); +} + .code-watermark { top: 5px; /* Account for extra padding and border in native editor */ } diff --git a/src/datascience-ui/native-editor/nativeEditor.tsx b/src/datascience-ui/native-editor/nativeEditor.tsx index 204046809856..60afeaefe45a 100644 --- a/src/datascience-ui/native-editor/nativeEditor.tsx +++ b/src/datascience-ui/native-editor/nativeEditor.tsx @@ -24,8 +24,6 @@ import { NativeEditorStateController } from './nativeEditorStateController'; // tslint:disable: react-this-binding-issue // tslint:disable-next-line:no-require-imports no-var-requires const debounce = require('lodash/debounce') as typeof import('lodash/debounce'); -// tslint:disable-next-line: no-require-imports -import cloneDeep = require('lodash/cloneDeep'); interface INativeEditorProps { skipDefault: boolean; @@ -100,7 +98,8 @@ export class NativeEditor extends React.Component 0 ? this.state.cellVMs[0].cell.id : undefined; const newCell = this.stateController.insertAbove(cellId, true); if (newCell) { - this.selectCell(newCell); + // Make async because the click changes focus. + setTimeout(() => this.focusCell(newCell, true), 0); } }; const addCellLine = this.state.cellVMs.length === 0 ? null : @@ -149,7 +148,7 @@ export class NativeEditor extends React.Component { // Cell should already exist in the UI if (this.contentPanelRef) { - const wasFocused = this.state.focusedCell !== undefined; + const wasFocused = this.state.focusedCellId !== undefined; this.stateController.selectCell(cellId, wasFocused ? cellId : undefined); this.focusCell(cellId, wasFocused ? true : false); } @@ -161,7 +160,7 @@ export class NativeEditor extends React.Component c.id === id)) { // Force selection change right now as we don't need the cell to exist // to make it selected (otherwise we'll get a flash) - const wasFocused = this.state.focusedCell !== undefined; + const wasFocused = this.state.focusedCellId !== undefined; this.stateController.selectCell(id, wasFocused ? id : undefined); // Then wait to give it actual input focus @@ -174,8 +173,12 @@ export class NativeEditor extends React.Component { - this.stateController.addNewCell(); + const newCell = this.stateController.addNewCell(); this.stateController.sendCommand(NativeCommandType.AddToEnd, 'mouse'); + if (newCell) { + // Has to be async because the click will change the focus on mouse up + setTimeout(() => this.focusCell(newCell.cell.id, true), 0); + } }; const runAll = () => { this.stateController.runAll(); @@ -297,8 +300,7 @@ export class NativeEditor extends React.Component this.focusCell(newCell, true), 0); } }; const lastLine = index === this.state.cellVMs.length - 1 ? @@ -400,12 +403,9 @@ export class NativeEditor extends React.Component @@ -414,6 +414,7 @@ export class NativeEditor extends React.Component { + this.stateController.selectCell(cellId, focusCode ? cellId : undefined); const ref = this.cellRefs.get(cellId); if (ref && ref.current) { ref.current.giveFocus(focusCode); @@ -421,13 +422,13 @@ export class NativeEditor extends React.Component { - if (this.state.newCell) { - const newCell = this.state.newCell; + if (this.state.newCellId) { + const newCell = this.state.newCellId; this.stateController.setState({newCell: undefined}); // Bounce this so state has time to update. setTimeout(() => { this.focusCell(newCell, true); - }, 10); + }, 0); } } } diff --git a/src/datascience-ui/native-editor/nativeEditorStateController.ts b/src/datascience-ui/native-editor/nativeEditorStateController.ts index 94c685b9d182..8dbe1a0bee26 100644 --- a/src/datascience-ui/native-editor/nativeEditorStateController.ts +++ b/src/datascience-ui/native-editor/nativeEditorStateController.ts @@ -5,7 +5,7 @@ import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import * as uuid from 'uuid/v4'; import { noop } from '../../client/common/utils/misc'; -import { concatMultilineString } from '../../client/datascience/common'; +import { concatMultilineStringInput } from '../../client/datascience/common'; import { Identifiers } from '../../client/datascience/constants'; import { ILoadAllCells, @@ -23,7 +23,6 @@ export class NativeEditorStateController extends MainStateController { constructor(props: IMainStateControllerProps) { super(props); } - // tslint:disable-next-line: no-any public handleMessage(msg: string, payload?: any) { // Handle message before base class so we will @@ -43,6 +42,21 @@ export class NativeEditorStateController extends MainStateController { this.waitingForLoadRender = true; break; + case InteractiveWindowMessages.NotebookRunAllCells: + this.runAll(); + break; + + case InteractiveWindowMessages.NotebookRunSelectedCell: + this.runSelectedCell(); + break; + + case InteractiveWindowMessages.NotebookAddCellBelow: + this.addNewCell(); + break; + case InteractiveWindowMessages.DoSave: + this.save(); + break; + default: break; } @@ -50,6 +64,12 @@ export class NativeEditorStateController extends MainStateController { return super.handleMessage(msg, payload); } + // This method is used by tests to prepare this react control for loading again. + public reset() { + this.waitingForLoadRender = false; + this.setState({ busy: true }); + } + public canMoveUp = (cellId?: string) => { const index = this.getState().cellVMs.findIndex(cvm => cvm.cell.id === cellId); return (index > 0); @@ -76,23 +96,36 @@ export class NativeEditorStateController extends MainStateController { return index > 0 && cells.find((cvm, i) => i >= index && cvm.cell.data.cell_type === 'code'); } + public runSelectedCell = () => { + const selectedCellId = this.getState().selectedCellId; + + if (selectedCellId) { + const cells = this.getState().cellVMs; + const selectedCell = cells.find(cvm => cvm.cell.id === selectedCellId); + if (selectedCell) { + this.submitInput(concatMultilineStringInput(selectedCell.cell.data.source), selectedCell); + } + } + } + public runAll = () => { // Run all code cells (markdown don't need to be run) this.suspendUpdates(); const cells = this.getState().cellVMs; cells.filter(cvm => cvm.cell.data.cell_type === 'code'). - forEach(cvm => this.submitInput(concatMultilineString(cvm.cell.data.source), cvm)); + forEach(cvm => this.submitInput(concatMultilineStringInput(cvm.cell.data.source), cvm)); this.resumeUpdates(); } public addNewCell = (): ICellViewModel | undefined => { const cells = this.getState().cellVMs; - const selectedCell = this.getState().selectedCell; + const selectedCell = this.getState().selectedCellId; this.suspendUpdates(); const id = uuid(); - const pos = selectedCell ? cells.findIndex(cvm => cvm.cell.id === this.getState().selectedCell) + 1 : cells.length; + const pos = selectedCell ? cells.findIndex(cvm => cvm.cell.id === this.getState().selectedCellId) + 1 : cells.length; this.setState({ newCell: id }); const vm = this.insertCell(createEmptyCell(id, null), pos); + this.sendMessage(InteractiveWindowMessages.InsertCell, { id, code: '', codeCellAbove: this.firstCodeCellAbove(id) }); if (vm) { // Make sure the new cell is monaco vm.useQuickEdit = false; @@ -112,7 +145,9 @@ export class NativeEditorStateController extends MainStateController { inputBlockShow: true, inputBlockText: '', inputBlockCollapseNeeded: false, - inputBlockToggled: noop + inputBlockToggled: noop, + selected: cells[0].selected, + focused: cells[0].focused }; this.setState({ cellVMs: [newVM], undoStack: this.pushStack(this.getState().undoStack, cells) }); } else { @@ -127,7 +162,7 @@ export class NativeEditorStateController extends MainStateController { if (index > 0) { this.suspendUpdates(); cells.filter((cvm, i) => i < index && cvm.cell.data.cell_type === 'code'). - forEach(cvm => this.submitInput(concatMultilineString(cvm.cell.data.source), cvm)); + forEach(cvm => this.submitInput(concatMultilineStringInput(cvm.cell.data.source), cvm)); this.resumeUpdates(); } } @@ -138,7 +173,7 @@ export class NativeEditorStateController extends MainStateController { if (index >= 0) { this.suspendUpdates(); cells.filter((cvm, i) => i >= index && cvm.cell.data.cell_type === 'code'). - forEach(cvm => this.submitInput(concatMultilineString(cvm.cell.data.source), cvm)); + forEach(cvm => this.submitInput(concatMultilineStringInput(cvm.cell.data.source), cvm)); this.resumeUpdates(); } } @@ -151,6 +186,7 @@ export class NativeEditorStateController extends MainStateController { const id = uuid(); this.setState({ newCell: id }); this.insertCell(createEmptyCell(id, null), index, isMonaco); + this.sendMessage(InteractiveWindowMessages.InsertCell, { id, code: '', codeCellAbove: this.firstCodeCellAbove(id) }); this.resumeUpdates(); return id; } @@ -164,6 +200,7 @@ export class NativeEditorStateController extends MainStateController { const id = uuid(); this.setState({ newCell: id }); this.insertCell(createEmptyCell(id, null), index + 1, isMonaco); + this.sendMessage(InteractiveWindowMessages.InsertCell, { id, code: '', codeCellAbove: this.firstCodeCellAbove(id) }); this.resumeUpdates(); return id; } @@ -179,6 +216,7 @@ export class NativeEditorStateController extends MainStateController { cellVMs: cellVms, undoStack: this.pushStack(this.getState().undoStack, origVms) }); + this.sendMessage(InteractiveWindowMessages.SwapCells, { firstCellId: cellId!, secondCellId: cellVms[index].cell.id }); } } @@ -192,6 +230,7 @@ export class NativeEditorStateController extends MainStateController { cellVMs: cellVms, undoStack: this.pushStack(this.getState().undoStack, origVms) }); + this.sendMessage(InteractiveWindowMessages.SwapCells, { firstCellId: cellId!, secondCellId: cellVms[index].cell.id }); } } @@ -239,24 +278,31 @@ export class NativeEditorStateController extends MainStateController { protected onCodeLostFocus(cellId: string) { // Update the cell's source - const cell = this.findCell(cellId); - if (cell) { + const index = this.findCellIndex(cellId); + if (index >= 0) { // Get the model for the monaco editor const monacoId = this.getMonacoId(cellId); if (monacoId) { const model = monacoEditor.editor.getModels().find(m => m.id === monacoId); if (model) { const newValue = model.getValue().replace(/\r/g, ''); - cell.cell.data.source = cell.inputBlockText = newValue; + const newVMs = [...this.getState().cellVMs]; + + // Update our state + newVMs[index] = { + ...newVMs[index], + cell: { + ...newVMs[index].cell, + data: { + ...newVMs[index].cell.data, + source: newValue + } + } + }; + + this.setState({ cellVMs: newVMs }); } } } - - // Special case markdown in the edit cell. Submit it. - if (cell && cell.cell.id === Identifiers.EditCellId && cell.cell.data.cell_type === 'markdown') { - const code = cell.inputBlockText; - cell.cell.data.source = cell.inputBlockText = ''; - this.submitInput(code, cell); - } } } diff --git a/src/datascience-ui/react-common/monacoEditor.tsx b/src/datascience-ui/react-common/monacoEditor.tsx index 6117fde784d7..accad9427343 100644 --- a/src/datascience-ui/react-common/monacoEditor.tsx +++ b/src/datascience-ui/react-common/monacoEditor.tsx @@ -33,11 +33,12 @@ export interface IMonacoEditorProps { lineCountChanged(newCount: number): void; } -interface IMonacoEditorState { +export interface IMonacoEditorState { editor?: monacoEditor.editor.IStandaloneCodeEditor; model: monacoEditor.editor.ITextModel | null; visibleLineCount: number; - attached: boolean; + attached: boolean; // Keeps track of when we reparent the editor out of the dummy dom node. + widgetsReparented: boolean; // Keeps track of when we reparent the hover widgets so they work inside something with overflow } // Need this to prevent wiping of the current value on a componentUpdate. react-monaco-editor has that problem. @@ -59,9 +60,18 @@ export class MonacoEditor extends React.Component(); this.measureWidthRef = React.createRef(); this.debouncedUpdateEditorSize = debounce(this.updateEditorSize.bind(this), 150); @@ -156,6 +166,17 @@ export class MonacoEditor extends React.Component { + this.hideParameterWidget(); + })); + + // Track focus changes to make sure we update our widget parent and widget position + this.subscriptions.push(editor.onDidFocusEditorWidget(() => { + this.throttledUpdateWidgetPosition(); + this.updateWidgetParent(editor); + })); + // Update our margin to include the correct line number style this.updateMargin(editor); @@ -185,7 +206,10 @@ export class MonacoEditor extends React.Component { + if (!this.widgetParent && !this.state.widgetsReparented && this.monacoContainer) { + // Only need to do this once, but update the widget parents and move them. + this.updateWidgetParent(this.state.editor); + this.startUpdateWidgetPosition(); + + // Since only doing this once, remove the listener. + this.monacoContainer.removeEventListener('mousemove', this.onContainerMove); + } + } + private onHoverLeave = () => { // If the hover is active, make sure to hide it. if (this.state.editor && this.widgetParent) { @@ -421,7 +455,106 @@ export class MonacoEditor extends React.Component this.hideParameterWidget(), 100); + } + } + + /** + * This will hide the parameter widget if the user is not hovering over + * the parameter widget for this monaco editor. + * + * Notes: See issue https://github.com/microsoft/vscode-python/issues/7851 for further info. + * Hide the parameter widget if all of the following conditions have been met: + * - ditor doesn't have focus + * - Mouse is not over the editor + * - Mouse is not over (hovering) the parameter widget + * + * @private + * @returns + * @memberof MonacoEditor + */ + private hideParameterWidget(){ + if (!this.state.editor || !this.state.editor.getDomNode() || !this.widgetParent){ + return; + } + // Find all elements that the user is hovering over. + // Its possible the parameter widget is one of them. + const hoverElements: Element[] = Array.prototype.slice.call(document.querySelectorAll(':hover')); + // Find all parameter widgets related to this monaco editor that are currently displayed. + const visibleParameterHintsWidgets: Element[] = Array.prototype.slice.call(this.widgetParent.querySelectorAll('.parameter-hints-widget.visible')); + if (hoverElements.length === 0 && visibleParameterHintsWidgets.length === 0){ + // If user is not hovering over anything and there are no visible parameter widgets, + // then, we have nothing to do but get out of here. + return; } + + // Find all parameter widgets related to this monaco editor. + const knownParameterHintsWidgets: HTMLDivElement[] = Array.prototype.slice.call(this.widgetParent.querySelectorAll('.parameter-hints-widget')); + + // Lets not assume we'll have the exact same DOM for parameter widgets. + // So, just remove the event handler, and add it again later. + if (this.parameterWidget){ + this.parameterWidget.removeEventListener('mouseleave', this.outermostParentLeave); + } + // These are the classes that will appear on a parameter widget when they are visible. + const parameterWidgetClasses = ['editor-widget', 'parameter-hints-widget', 'visible']; + + // Find the parameter widget the user is currently hovering over. + this.parameterWidget = hoverElements.find(item => { + if (!item.className) { + return false; + } + // Check if user is hovering over a parameter widget. + const classes = item.className.split(' '); + if (!parameterWidgetClasses.every(cls => classes.indexOf(cls) >= 0)){ + // Not all classes required in a parameter hint widget are in this element. + // Hence this is not a parameter widget. + return false; + } + + // Ok, this element that the user is hovering over is a parameter widget. + + // Next, check whether this parameter widget belongs to this monaco editor. + // We have a list of parameter widgets that belong to this editor, hence a simple lookup. + return knownParameterHintsWidgets.some(widget => widget === item); + }); + + if (this.parameterWidget){ + // We know the user is hovering over the parameter widget for this editor. + // Hovering could mean the user is scrolling through a large parameter list. + // We need to add a mouse leave event handler, so as to hide this. + this.parameterWidget.addEventListener('mouseleave', this.outermostParentLeave); + + // In case the event handler doesn't get fired, have a backup of checking within 1s. + setTimeout(() => this.hideParameterWidget(), 1000); + return; + } + if (visibleParameterHintsWidgets.length === 0){ + // There are no parameter widgets displayed for this editor. + // Hence nothing to do. + return; + } + // If the editor has focus, don't hide the parameter widget. + // This is the default behavior. Let the user hit `Escape` or click somewhere + // to forcefully hide the parameter widget. + if (this.state.editor.hasWidgetFocus()) { + return; + } + + // If we got here, then the user is not hovering over the paramter widgets. + // & the editor doesn't have focus. + // However some of the parameter widgets associated with this monaco editor are visible. + // We need to hide them. + + // Solution: Hide the widgets manually. + knownParameterHintsWidgets.forEach(widget => { + widget.setAttribute('class', widget.className.split(' ').filter(cls => cls !== 'visible').join(' ')); + if (widget.style.visibility !== 'hidden') { + widget.style.visibility = 'hidden'; + } + }); } private updateMargin(editor: monacoEditor.editor.IStandaloneCodeEditor) { @@ -452,14 +585,15 @@ export class MonacoEditor extends React.Component