From 3bfb053248ade9fa0301e7699b4691320243ce62 Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Wed, 3 Sep 2025 16:18:15 +0200 Subject: [PATCH 1/5] feat: Open a Deepnote file --- .github/ISSUE_TEMPLATE/2_bug_form.yml | 4 +- .github/ISSUE_TEMPLATE/config.yml | 4 +- .github/workflows/aggregate-test-results.yml | 43 - .github/workflows/build-test.yml | 861 ------------------ .github/workflows/ci.yml | 75 ++ .github/workflows/codeql-analysis.yml | 75 -- .github/workflows/deps.yml | 66 ++ .github/workflows/lock.yml | 27 - CLAUDE.md | 29 + package-lock.json | 335 +++---- package.json | 46 +- package.nls.json | 3 +- src/notebooks/deepnote/MimeTypeProcessor.ts | 169 ++++ src/notebooks/deepnote/OutputTypeDetector.ts | 51 ++ src/notebooks/deepnote/dataConversionUtils.ts | 91 ++ .../deepnote/deepnoteActivationService.ts | 193 ++++ .../deepnote/deepnoteDataConverter.ts | 162 ++++ .../deepnoteDataConverter.unit.test.ts | 458 ++++++++++ .../deepnote/deepnoteNotebookManager.ts | 91 ++ .../deepnoteNotebookManager.unit.test.ts | 230 +++++ .../deepnote/deepnoteNotebookSelector.ts | 83 ++ .../deepnoteNotebookSelector.unit.test.ts | 147 +++ src/notebooks/deepnote/deepnoteSerializer.ts | 203 +++++ src/notebooks/deepnote/deepnoteTypes.ts | 62 ++ .../outputHandlers/ErrorOutputHandler.ts | 106 +++ .../outputHandlers/RichOutputHandler.ts | 73 ++ .../outputHandlers/StreamOutputHandler.ts | 55 ++ src/notebooks/serviceRegistry.node.ts | 5 + src/notebooks/serviceRegistry.web.ts | 5 + src/platform/common/constants.ts | 1 + .../api/kernels/kernel.unit.test.ts | 1 + 31 files changed, 2572 insertions(+), 1182 deletions(-) delete mode 100644 .github/workflows/aggregate-test-results.yml delete mode 100644 .github/workflows/build-test.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/deps.yml delete mode 100644 .github/workflows/lock.yml create mode 100644 CLAUDE.md create mode 100644 src/notebooks/deepnote/MimeTypeProcessor.ts create mode 100644 src/notebooks/deepnote/OutputTypeDetector.ts create mode 100644 src/notebooks/deepnote/dataConversionUtils.ts create mode 100644 src/notebooks/deepnote/deepnoteActivationService.ts create mode 100644 src/notebooks/deepnote/deepnoteDataConverter.ts create mode 100644 src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts create mode 100644 src/notebooks/deepnote/deepnoteNotebookManager.ts create mode 100644 src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts create mode 100644 src/notebooks/deepnote/deepnoteNotebookSelector.ts create mode 100644 src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts create mode 100644 src/notebooks/deepnote/deepnoteSerializer.ts create mode 100644 src/notebooks/deepnote/deepnoteTypes.ts create mode 100644 src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts create mode 100644 src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts create mode 100644 src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts diff --git a/.github/ISSUE_TEMPLATE/2_bug_form.yml b/.github/ISSUE_TEMPLATE/2_bug_form.yml index 2b11b63f65..2c4b17578c 100644 --- a/.github/ISSUE_TEMPLATE/2_bug_form.yml +++ b/.github/ISSUE_TEMPLATE/2_bug_form.yml @@ -8,8 +8,8 @@ body: attributes: value: | ## Before submitting a bug, you may want to check out... - * Troubleshooting on [our wiki](https://github.com/microsoft/vscode-jupyter/wiki) - * Our [Discussions Forum](https://github.com/microsoft/vscode-jupyter/discussions) + * Troubleshooting on [our wiki](https://github.com/deepnote/vscode-extension/wiki) + * Our [Discussions Forum](https://github.com/deepnote/vscode-extension/discussions) - type: markdown attributes: value: | diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a8f0e9901e..acf3d19b67 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: 'Help and Support' - url: https://github.com/microsoft/vscode-jupyter/discussions/categories/questions-and-answers - about: "Need help getting something to work, but you're not sure it's worth of submitting an issue? Ask here." + url: https://github.com/deepnote/vscode-extension/discussions + about: "Need help getting something to work, but you're not sure it's worth submitting an issue? Ask here." - name: 'Chat on Discord' url: https://aka.ms/python-discord about: 'You can ask for help or chat in the `#jupyter` channel of our microsoft-python Discord server' diff --git a/.github/workflows/aggregate-test-results.yml b/.github/workflows/aggregate-test-results.yml deleted file mode 100644 index e76b5e3a86..0000000000 --- a/.github/workflows/aggregate-test-results.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Aggregate Test Results - -on: - schedule: - - cron: '0 2 * * *' # daily at 2 am - workflow_dispatch: - inputs: - collection_date: - description: 'Which day to collect test results from. Format yyyy-mm-dd. If empty, the previous day is used.' - type: string - required: false - -jobs: - getTestResults: - name: Get Test Results - runs-on: ubuntu-latest - - permissions: - actions: read - - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: setup python - uses: actions/setup-python@v5 - - - name: Install Python libs - run: python -m pip install requests - - - name: Aggregate yesterdays test results - run: python ./pythonFiles/aggregateTestResults.py ${{ secrets.GITHUB_TOKEN }} "${COLLECTION_DATE}" - env: - COLLECTION_DATE: ${{ inputs.collection_date }} - - - name: Upload test result files - uses: actions/upload-artifact@v4 - with: - name: testResults-${{ inputs.collection_date }} - path: AggTestResults-*.json - retention-days: 60 diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml deleted file mode 100644 index 9c2272b78b..0000000000 --- a/.github/workflows/build-test.yml +++ /dev/null @@ -1,861 +0,0 @@ -# This yml is used for PRs, pre-release, and release build. -# We use the github.event_name to determine what started the workflow to determine which -# situation we are in. - -name: Build and Test - -permissions: {} - -on: - pull_request: - branches: - - main - - 'release' - - 'release/*' - - 'release-*' - check_run: - types: [rerequested, requested_action] - push: - branches: - - main - - 'release' - - 'release/*' - - 'release-*' - schedule: - - cron: '0 9 * * 1-5' # 9am UTC, Monday-Friday (2am PDT, after VS Code pre-release builds which is 11pm PDT) - workflow_dispatch: - -env: - NODE_VERSION: 22.15.1 - NPM_VERSION: 10.9.2 - PYTHON_VERSION: 3.12 - DENO_VERSION: '~1.37' - MOCHA_REPORTER_JUNIT: true # Use the mocha-multi-reporters and send output to both console (spec) and JUnit (mocha-junit-reporter). Also enables a reporter which exits the process running the tests if it haven't already. - CACHE_NPM_DEPS: cache-npm - CACHE_OUT_DIRECTORY: cache-out-directory - CACHE_PIP_DEPS: cache-pip - VSC_JUPYTER_FORCE_LOGGING: 'true' - VSC_PYTHON_FORCE_LOGGING: 'true' - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 'true' - # Key for the cache created at the end of the the 'Cache ./pythonFiles/lib/python' step. - CACHE_PYTHONFILES: cache-pvsc-pythonFiles - TEST_RESULTS_DIRECTORY: . - TEST_RESULTS_GLOB: '**/test-results*.xml' - IPYWIDGET_SCREENSHOT_PATH: '*-screenshot.png' - DISABLE_INSIDERS_EXTENSION: 1 # Disable prompts to install pre-release in tests (else it blocks activation of extension). - VSC_JUPYTER_INSTRUMENT_CODE_FOR_COVERAGE: true - VSC_JUPYTER_LOG_KERNEL_OUTPUT: true - -jobs: - # Make sure to cancel previous runs on a push - cancel_previous_runs: - runs-on: ubuntu-latest - permissions: - actions: write - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1 - with: - access_token: ${{ github.token }} - - build-vsix: - name: Build VSIX - runs-on: ubuntu-latest - if: github.repository == 'microsoft/vscode-jupyter' - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Use Node ${{env.NODE_VERSION}} - uses: actions/setup-node@v4 - with: - node-version: ${{env.NODE_VERSION}} - - - name: Use Npm ${{env.NPM_VERSION}} - run: npm i -g npm@${{env.NPM_VERSION}} - - - name: Use Python ${{env.PYTHON_VERSION}} - uses: actions/setup-python@v5 - with: - python-version: ${{env.PYTHON_VERSION}} - - # Caching of npm packages (https://github.com/actions/cache/blob/main/examples.md#node---npm) - - name: Cache npm on linux/mac - uses: actions/cache@v4 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - run: npm i -g @vscode/vsce - - - name: Build VSIX - uses: ./.github/actions/build-vsix - id: build-vsix - - - uses: actions/upload-artifact@v4 - with: - name: 'ms-toolsai-jupyter-insiders.vsix' - path: 'ms-toolsai-jupyter-insiders.vsix' - - lint: - name: Lint - runs-on: ubuntu-latest - if: github.repository == 'microsoft/vscode-jupyter' - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Use Node ${{env.NODE_VERSION}} - uses: actions/setup-node@v4 - with: - node-version: ${{env.NODE_VERSION}} - - - name: Use Npm ${{env.NPM_VERSION}} - run: npm i -g npm@${{env.NPM_VERSION}} - - - name: Cache pip files - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{runner.os}}-${{env.CACHE_PIP_DEPS}}-${{env.PYTHON_VERSION}} - - - name: Cache npm files - uses: actions/cache@v4 - with: - path: ~/.npm - key: ${{runner.os}}-${{env.CACHE_NPM_DEPS}}-${{hashFiles('package-lock.json')}} - - - name: Cache the out/ directory - uses: actions/cache@v4 - with: - path: ./out - key: ${{runner.os}}-${{env.CACHE_OUT_DIRECTORY}}-${{hashFiles('src/**')}} - - # This is faster than running `npm ci`, we do not want to build zmq, etc. - # Let that happen in other jobs, this job needs to be fast - - name: npm ci - run: npm ci --ignore-scripts --prefer-offline --no-audit - - - name: npm run postinstall - run: npm run postinstall - - - name: Verify Translation files - run: npx gulp validateTranslationFiles - - - name: Run linting on TypeScript code (eslint) - run: npm run lint - - - name: Run prettier on JavaScript code - run: npm run format - - - name: Use Python ${{env.PYTHON_VERSION}} - uses: actions/setup-python@v5 - with: - python-version: ${{env.PYTHON_VERSION}} - - - name: Run Black on Python code - run: | - python -m pip install click==8.0.4 - python -m pip install -U black - python -m black . --check - working-directory: pythonFiles - - - name: Compile - run: npm run compile - - - name: Check dependencies - run: npm run checkDependencies - - - name: Check changes to package-lock.json - run: npx gulp validatePackageLockJson - - - name: Validate TELEMETRY files - run: npx gulp validateTelemetry - - - name: Verify usage of new Proposed API - if: github.event_name == 'pull_request' - run: npx tsx ./build/verifyProposedApiUsage.ts - env: - PULL_REQUEST_SHA: ${{ github.sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - - ts_tests: - name: Type Script Tests - runs-on: ${{ matrix.os }} - if: github.repository == 'microsoft/vscode-jupyter' - strategy: - fail-fast: false - matrix: - os: ['ubuntu-latest', 'windows-latest'] - test-suite: [ts-unit] - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Use Node ${{env.NODE_VERSION}} - uses: actions/setup-node@v4 - with: - node-version: ${{env.NODE_VERSION}} - - - name: Use Npm ${{env.NPM_VERSION}} - run: npm i -g npm@${{env.NPM_VERSION}} - - # Caching of npm packages (https://github.com/actions/cache/blob/main/examples.md#node---npm) - - name: Cache npm on linux/mac - uses: actions/cache@v4 - if: matrix.os != 'windows-latest' - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Get npm cache directory - if: matrix.os == 'windows-latest' - id: npm-cache - run: | - echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - # - name: Cache npm on windows - # uses: actions/cache@v4 - # if: matrix.os == 'windows-latest' - # with: - # path: ${{ steps.npm-cache.outputs.dir }} - # key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - # restore-keys: | - # ${{ runner.os }}-node- - - - name: Cache compiled TS files - # Use an id for this step so that its cache-hit output can be accessed and checked in the next step. - id: out-cache - uses: actions/cache@v4 - with: - path: ./out - key: ${{runner.os}}-${{env.CACHE_OUT_DIRECTORY}}-${{hashFiles('src/**')}} - - # This is faster than running `npm ci`, we do not want to build zmq, etc. - # Let that happen in other jobs, this job needs to be fast - - name: npm ci - run: npm ci --ignore-scripts --prefer-offline --no-audit - - - name: npm run postinstall - run: npm run postinstall - - - name: Compile if not cached - run: npm run compile - if: steps.out-cache.outputs.cache-hit != 'true' - - - name: Run TypeScript unit tests - id: test_unittests - run: npm run test:unittests - - - name: Verify there are no unhandled errors - run: npx gulp verifyUnhandledErrors - - vscodeTests: - name: Tests # These tests run with Python extension & real Jupyter - runs-on: ${{ matrix.os }} - if: github.repository == 'microsoft/vscode-jupyter' - env: - VSC_PYTHON_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 1 - strategy: - fail-fast: false - matrix: - jupyterConnection: [raw] # valid values include raw = uzing zqm, local = using local jupyter, remote = using remote jupyter, web = using remote jupyter in web mode - python: [python] # valid values include python,conda,noPython - pythonVersion: ['3.10'] - # Whether we're using stable () or pre-release versions of Python packages. - # When installing pre-release versions, we're only focused on jupyter & related packages. - # Not pre-release versions of pandas, numpy or other such packages that are not core to Jupyter. - packageVersion: [''] - tags: [ - '^[^@]+$|@mandatory|@kernelCore|@python|@jupyter', - '@widgets', - '@iw', - '@webview|@export|@lsp|@variableViewer', - '@debugger', - '@notebookPerformance', # Disabled for now, separate PR this will be enabled via cron (want to make PR smaller) - '@executionPerformance' - ] - # We're not running CI on macOS for now because it's one less matrix entry to lower the number of runners used, - # macOS runners are expensive, and we assume that Ubuntu is enough to cover the UNIX case. - os: [ubuntu-latest] - ipywidgetsVersion: [''] - isScheduled: - - ${{ github.event_name == 'schedule' && 'true' || '' }} # We need an empty value to preserve the matrix name (as we enforce mandatory tests based on names). - # More details on includes/excludes can be found here https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#example-including-additional-values-into-combinations - # Basically with exclude, you can exclude any of the combinations from the result matrix. - # & with include, you can include additional items to the result matrix. - exclude: - - tags: '@notebookPerformance' # Run only as part of scheduled CI runs - isScheduled: '' - - tags: '@executionPerformance' # Run only as part of scheduled CI runs - isScheduled: '' - include: - # Mandatory tests - - jupyterConnection: remote - python: python - pythonVersion: '3.10' - tags: '^[^@]+$|@mandatory' - os: ubuntu-latest - ipywidgetsVersion: '' - - jupyterConnection: remote - python: python - pythonVersion: '3.10' - packageVersion: 'prerelease' - tags: '^[^@]+$|@mandatory' - os: ubuntu-latest - ipywidgetsVersion: '' - - jupyterConnection: web - python: python - pythonVersion: '3.10' - tags: '^[^@]+$|@mandatory' - os: ubuntu-latest - ipywidgetsVersion: '' - - jupyterConnection: raw - python: python - pythonVersion: '3.10' - tags: '^[^@]+$|@mandatory' - os: windows-latest - ipywidgetsVersion: '' - # IPyWidgets 7 & 8 - - jupyterConnection: raw - python: python - pythonVersion: '3.10' - tags: '@widgets' - os: ubuntu-latest - ipywidgetsVersion: '7' - - jupyterConnection: raw - python: python - pythonVersion: '3.10' - tags: '@widgets' - os: ubuntu-latest - ipywidgetsVersion: '' - - jupyterConnection: remote - python: python - pythonVersion: '3.10' - tags: '@widgets' - os: ubuntu-latest - ipywidgetsVersion: '' - # Conda - - jupyterConnection: raw - python: conda - pythonVersion: '3.10' - tags: '^[^@]+$|@mandatory|@python' - os: ubuntu-latest - ipywidgetsVersion: '' - # Pre-Release Versions - - jupyterConnection: raw - python: python - pythonVersion: '3.10' - packageVersion: 'prerelease' - tags: '^[^@]+$|@mandatory|@kernelCore|@python|@jupyter|@widgets' - os: ubuntu-latest - ipywidgetsVersion: '' - # Without Python - - jupyterConnection: raw - python: noPython - tags: '@nonPython' - os: ubuntu-latest - ipywidgetsVersion: '' - # Misc - - jupyterConnection: remote - python: python - pythonVersion: '3.10' - tags: '@kernelCore' - os: ubuntu-latest - ipywidgetsVersion: '' - - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Generate Tag Variable - run: echo "TAGSVAR=${{ matrix.tags }}}" >> $GITHUB_ENV - - - name: Generate Package PreRelease Variable - run: echo "PACKAGE_PRE_RELEASE=${{ matrix.packageVersion }}" >> $GITHUB_ENV - - - name: Generate Friendly Variable - run: - # Generate a file friendly tag name for the test run (used for the artifacts) - echo "TAGS_NAME=${TAGSVAR//[^a-zA-Z]/_}" >> $GITHUB_ENV - - - name: Use Python ${{matrix.pythonVersion}} - uses: actions/setup-python@v5 - id: setupPythonVersion - if: matrix.python != 'conda' && matrix.python != 'noPython' - with: - python-version: ${{matrix.pythonVersion}} - - - name: Install Conda environment from environment.yml - uses: mamba-org/setup-micromamba@b09ef9b599704322748535812ca03efb2625677b # v2.0.5 - if: matrix.python == 'conda' - with: - cache-downloads: true - environment-name: functional_test_env - environment-file: ./build/conda-test-requirements.yml - create-args: | - python=${{matrix.pythonVersion}} - - - name: Set CI Path - shell: bash - if: matrix.python != 'conda' && matrix.python != 'noPython' - run: echo "CI_PYTHON_PATH=${{ steps.setupPythonVersion.outputs.python-path }}" >> $GITHUB_ENV - - - name: Set CI Path for Conda - uses: ./.github/actions/set-python-conda - id: set-python-conda - if: matrix.python == 'conda' - with: - PYTHON_VERSION: ${{matrix.pythonVersion}} - - - name: Use Node ${{env.NODE_VERSION}} - uses: actions/setup-node@v4 - with: - node-version: ${{env.NODE_VERSION}} - - - name: Use Npm ${{env.NPM_VERSION}} - run: npm i -g npm@${{env.NPM_VERSION}} - - # Start caching - - # Cache Python Dependencies. - # Caching (https://github.com/actions/cache/blob/main/examples.md#python---pip - - name: Cache pip on linux - uses: actions/cache@v4 - if: matrix.os == 'ubuntu-latest' && matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion == '7' - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-${{hashFiles('build/venv-test-ipywidgets7-requirements.txt')}} - restore-keys: | - ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-7 - - - name: Cache pip on linux - uses: actions/cache@v4 - if: matrix.os == 'ubuntu-latest' && matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion != '7' - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-${{hashFiles('build/venv-test-ipywidgets8-requirements.txt')}} - restore-keys: | - ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-8 - - - name: Cache pip on mac - uses: actions/cache@v4 - if: matrix.os == 'macos-latest' && matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion == '7' - with: - path: ~/Library/Caches/pip - key: ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-${{hashFiles('build/venv-test-ipywidgets7-requirements.txt')}} - restore-keys: | - ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-7 - - - name: Cache pip on mac - uses: actions/cache@v4 - if: matrix.os == 'macos-latest' && matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion != '7' - with: - path: ~/Library/Caches/pip - key: ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-${{hashFiles('build/venv-test-ipywidgets8-requirements.txt')}} - restore-keys: | - ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-8 - - - name: Cache pip on windows - uses: actions/cache@v4 - if: matrix.os == 'windows-latest' && matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion == '7' - with: - path: ~\AppData\Local\pip\Cache - key: ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-${{hashFiles('build/venv-test-ipywidgets7-requirements.txt')}} - restore-keys: | - ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-7 - - - name: Cache pip on windows - uses: actions/cache@v4 - if: matrix.os == 'windows-latest' && matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion != '7' - with: - path: ~\AppData\Local\pip\Cache - key: ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-${{hashFiles('build/venv-test-ipywidgets8-requirements.txt')}} - restore-keys: | - ${{ runner.os }}-pip-${{env.PYTHON_VERSION}}-8 - - # Caching of npm packages (https://github.com/actions/cache/blob/main/examples.md#node---npm) - - name: Cache npm on linux/mac - uses: actions/cache@v4 - if: matrix.os != 'windows-latest' - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Get npm cache directory - if: matrix.os == 'windows-latest' - id: npm-cache - run: | - echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - # - name: Cache npm on windows - # uses: actions/cache@v4 - # if: matrix.os == 'windows-latest' - # with: - # path: ${{ steps.npm-cache.outputs.dir }} - # key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - # restore-keys: | - # ${{ runner.os }}-node- - - # For faster/better builds of sdists. - - run: python -m pip install wheel - shell: bash - if: matrix.python != 'conda' && matrix.python != 'noPython' - - # Install the pre-release versions of the packages - - name: Install Pre-Release Python Libs - if: matrix.python != 'conda' && matrix.python != 'noPython' && matrix.packageVersion == 'prerelease' - run: | - python --version - python -c "import sys;print(sys.executable)" - python -m pip install jupyter notebook ipykernel ipython nbconvert nbformat jupyterlab traitlets --pre - - # debugpy is not shipped, only installed for local tests. - # In production, we get debugpy from python extension. - - name: Install Python Libs - if: matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion == '7' && matrix.tags != '@notebookPerformance' - run: | - python --version - python -c "import sys;print(sys.executable)" - python -m pip --disable-pip-version-check install -r build/venv-test-ipywidgets7-requirements.txt - python -m pip install packaging # required for installing debugpy - python -m pip install --upgrade notebook - python ./pythonFiles/install_debugpy.py - python -m ipykernel install --user - - - name: Install Jupyter Notebook and Lab - if: matrix.jupyterConnection == 'remote' || matrix.jupyterConnection == 'web' - run: | - python -m pip install --upgrade jupyterlab notebook - - - name: Install Python Libs (IPyWidget 8) - if: matrix.python != 'conda' && matrix.python != 'noPython' && matrix.ipywidgetsVersion != '7' && matrix.tags != '@notebookPerformance' - run: | - python --version - python -c "import sys;print(sys.executable)" - python -m pip --disable-pip-version-check install -r build/venv-test-ipywidgets8-requirements.txt - python -m pip install packaging # required for installing debugpy - python ./pythonFiles/install_debugpy.py - python -m ipykernel install --user - - - name: Install Python Libs for conda - shell: bash -l {0} - if: matrix.python == 'conda' - run: | - python -m pip --disable-pip-version-check install -r build/conda-nonconda-test-requirements.txt - python -m pip install packaging # required for installing debugpy - python ./pythonFiles/install_debugpy.py - - # - name: Install matplotlib widgets into user and system paths - # if: - # matrix.os == 'ubuntu-latest' && matrix.python != 'conda' && matrix.python != 'noPython' && matrix.packageVersion != 'prerelease' && matrix.tags != '^[^@]+$|@mandatory' && matrix.tags != '@notebookPerformance' - # # This test will ensure widgets work when installed in 3 places - # # 1. In python environments site-packages folder (we have other 3rd party widgets in the python env) - # # 2. In user's home folder (ipympl will be installed in there) - # # 3. In system folder (all users) (matplotlib will be installed in there) - # run: | - # # Uninstall ipympl from the sys prefix folder and ensure the widget scripts are installed - # # into the user directory. - # export PYTHON_EXECUTABLE=$(which python) - # echo $PYTHON_EXECUTABLE - # python -m jupyter nbextension uninstall --sys-prefix --py ipympl - # sudo $PYTHON_EXECUTABLE -m jupyter nbextension install --system --py ipympl - - # Run npm install (we need chrome to get downloaded) - - name: npm ci - if: matrix.jupyterConnection == 'web' - uses: ./.github/actions/npm-ci - - # This is faster than running `npm ci`, we do not want to build zmq, etc. - # Let that happen in other jobs, this job needs to be super fast for linting - - name: npm ci - if: matrix.jupyterConnection != 'web' - run: npm ci --ignore-scripts --prefer-offline --no-audit - - - name: npm run postinstall - if: matrix.jupyterConnection != 'web' - run: npm run postinstall - - - name: Install screen capture dependencies - if: matrix.os == 'ubuntu-latest' - run: sudo apt-get update && sudo apt-get install --fix-missing imagemagick x11-xserver-utils - - # This step is slow. - - name: Compile # if not cached - run: npm run compile - # Do not cache for web tests, as the code generated in the compiled code is different for each matrix in web tests - # Rememeber the compiled code contains injected tests, and the injected tests are different for each matrix in the web - # if: steps.out-cache.outputs.cache-hit != 'true' && matrix.jupyterConnection != 'web' - env: - VSC_JUPYTER_CI_TEST_GREP: ${{ matrix.tags }} - VSC_JUPYTER_CI_SKIP_WEB_BUNDLE: 1 - VSC_JUPYTER_CI_FAST_COMPILATION: 1 - - - name: Compile Performance Test Extension - if: matrix.tags == '@notebookPerformance' - run: npm run compile - working-directory: src/test/vscode-notebook-perf - - # Used by tests for non-python kernels. - # Test are enabled via env variable `VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST` - - name: Install Deno - uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 - if: matrix.tags != '@notebookPerformance' - with: - deno-version: ${{ env.DENO_VERSION}} - - - name: Install Deno Kernel - if: matrix.tags != '@notebookPerformance' - run: npx tsx ./build/installDenoKernel.ts - - - name: Create Virtual Env for Tests - uses: ./.github/actions/create-venv-for-tests - if: matrix.python != 'conda' && matrix.python != 'noPython' && matrix.os != 'windows-latest' && matrix.jupyterConnection != 'remote' && matrix.tags != '^[^@]+$|@mandatory' && matrix.ipywidgetsVersion == '7' && matrix.jupyterConnection != 'web' && matrix.jupyterConnection != 'remote' && matrix.tags != '@debugger' && matrix.tags != '@webview|@export|@lsp|@variableViewer' && matrix.tags != '@notebookPerformance' - with: - IPyWidgetVersion: '7' - - - name: Create Virtual Env for Tests (ipywidgets 8) - uses: ./.github/actions/create-venv-for-tests - if: matrix.python != 'conda' && matrix.python != 'noPython' && matrix.os != 'windows-latest' && matrix.jupyterConnection != 'remote' && matrix.tags != '^[^@]+$|@mandatory' && matrix.ipywidgetsVersion != '7' && matrix.jupyterConnection != 'web' && matrix.jupyterConnection != 'remote' && matrix.tags != '@debugger' && matrix.tags != '@webview|@export|@lsp|@variableViewer' && matrix.tags != '@notebookPerformance' - with: - IPyWidgetVersion: '8' - - - name: Create temp folder for user data dir - run: | - echo "VSC_JUPYTER_USER_DATA_DIR=$env:USERPROFILE\AppData\Local\Temp" >> $Env:GITHUB_ENV - if: matrix.os == 'windows-latest' - shell: pwsh - - - name: Print temp folder for user data dir - run: | - echo ${{env.VSC_JUPYTER_USER_DATA_DIR}} is user data dir - if: matrix.os == 'windows-latest' - shell: pwsh - - - name: Create temp folder for user data dir - run: | - echo "VSC_JUPYTER_USER_DATA_DIR=$(mktemp -d)" >> $GITHUB_ENV - echo ${{env.VSC_JUPYTER_USER_DATA_DIR}} is user data dir - if: matrix.os != 'windows-latest' - - # Set the correct xvfb commands to run vscode tests - # https://code.visualstudio.com/api/working-with-extensions/continuous-integration - # Note that xvfb github action only runs through xvfb on linux, so only set the commands there - # as on windows / mac they would get directly passed to run, not xvfb-run - - name: Set xvfb parameters linux - if: matrix.os == 'ubuntu-latest' - run: echo "xvfbCommand=--server-args=\"-screen 0 1024x768x24\"" >> $GITHUB_ENV - - - name: Run Native Notebook with VSCode & Jupyter (ubuntu) - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 - with: - run: ${{ env.xvfbCommand }} npm run test:integration - env: - VSC_JUPYTER_FORCE_LOGGING: 1 - VSC_PYTHON_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 1 - VSC_JUPYTER_REMOTE_NATIVE_TEST: ${{ matrix.jupyterConnection == 'remote' }} - VSC_JUPYTER_NON_RAW_NATIVE_TEST: ${{ matrix.jupyterConnection == 'local' }} - VSC_JUPYTER_CI_RUN_JAVA_NB_TEST: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_IS_CONDA: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_TEST_VSC_CHANNEL: 'insiders' - VSC_JUPYTER_CI_TEST_GREP: ${{ matrix.tags }} - id: test_notebook_vscode_ubuntu - if: matrix.python != 'noPython' && matrix.os == 'ubuntu-latest' && matrix.jupyterConnection != 'web' && matrix.tags != '@notebookPerformance' - - - name: Run Notebook Perf Test Without Jupyter - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 - with: - run: ${{ env.xvfbCommand }} npm run test:performance:notebook - env: - VSC_JUPYTER_FORCE_LOGGING: 1 - VSC_PYTHON_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 1 - VSC_JUPYTER_CI_TEST_DO_NOT_INSTALL_PYTHON_EXT: ${{ matrix.matrix == '@notebookPerformance' }} - VSC_JUPYTER_REMOTE_NATIVE_TEST: ${{ matrix.jupyterConnection == 'remote' }} - VSC_JUPYTER_NON_RAW_NATIVE_TEST: ${{ matrix.jupyterConnection == 'local' }} - VSC_JUPYTER_CI_RUN_JAVA_NB_TEST: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_IS_CONDA: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_TEST_VSC_CHANNEL: 'insiders' - VSC_JUPYTER_CI_TEST_GREP: ${{ matrix.tags }} - id: test_notebook_perf_vscode_ubuntu - if: matrix.tags == '@notebookPerformance' - - - name: Run Execution Perf Test With Jupyter - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 - with: - run: ${{ env.xvfbCommand }} npm run test:performance:execution - env: - VSC_JUPYTER_FORCE_LOGGING: 1 - VSC_PYTHON_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 1 - VSC_JUPYTER_REMOTE_NATIVE_TEST: ${{ matrix.jupyterConnection == 'remote' }} - VSC_JUPYTER_NON_RAW_NATIVE_TEST: ${{ matrix.jupyterConnection == 'local' }} - VSC_JUPYTER_CI_RUN_JAVA_NB_TEST: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_IS_CONDA: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_TEST_VSC_CHANNEL: 'insiders' - VSC_JUPYTER_CI_TEST_GREP: ${{ matrix.tags }} - id: test_exec_perf_vscode_ubuntu - if: matrix.tags == '@executionPerformance' - - - name: Build web bundle for testing - run: npm run compile - if: matrix.python != 'noPython' && matrix.os == 'ubuntu-latest' && matrix.jupyterConnection == 'web' - env: - VSC_JUPYTER_CI_TEST_GREP: ${{ matrix.tags }} - - - name: Run Native Notebook with VSCode & Jupyter (web) - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 - with: - run: npm run test:integration:web - env: - VSC_JUPYTER_FORCE_LOGGING: 1 - VSC_PYTHON_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 1 - VSC_JUPYTER_REMOTE_NATIVE_TEST: ${{ matrix.jupyterConnection == 'web' }} - VSC_JUPYTER_NON_RAW_NATIVE_TEST: ${{ matrix.jupyterConnection != 'web' }} - VSC_JUPYTER_CI_RUN_JAVA_NB_TEST: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_IS_CONDA: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_TEST_VSC_CHANNEL: 'insiders' - VSC_JUPYTER_CI_TEST_GREP: ${{ matrix.tags }} - id: test_notebook_vscode_web - if: matrix.python != 'noPython' && matrix.os == 'ubuntu-latest' && matrix.jupyterConnection == 'web' - - - name: Run Native Notebook with VSCode & Jupyter (windows) - # Running tests on Windows is verys low, hence run only a few of the tests. - run: | - npm run test:integration:windows - env: - VSC_JUPYTER_FORCE_LOGGING: 1 - VSC_PYTHON_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 1 - VSC_JUPYTER_REMOTE_NATIVE_TEST: ${{ matrix.jupyterConnection == 'remote' }} - VSC_JUPYTER_NON_RAW_NATIVE_TEST: ${{ matrix.jupyterConnection == 'local' }} - VSC_JUPYTER_CI_RUN_JAVA_NB_TEST: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_IS_CONDA: ${{ matrix.python == 'conda' }} - VSC_JUPYTER_CI_TEST_VSC_CHANNEL: 'insiders' - VSC_PYTHON_LOG_FILE: ${{env.VSC_JUPYTER_USER_DATA_DIR}}/logs/python.log - TEST_FILES_SUFFIX: '+(interrupt|execut)*.vscode.test*' - id: test_notebook_vscode_windows - if: matrix.python != 'noPython' && matrix.os == 'windows-latest' && matrix.jupyterConnection != 'web' - - - name: Run Native Notebook with VSCode & Jupyter (without Python) - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 - with: - run: ${{ env.xvfbCommand }} npm run test:integration:nonpython - env: - VSC_JUPYTER_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_RUN_NON_PYTHON_NB_TEST: 1 - VSC_JUPYTER_CI_TEST_VSC_CHANNEL: 'insiders' - VSC_JUPYTER_CI_TEST_GREP: ${{ matrix.tags }} - id: test_notebookWithoutPythonExt_vscode - if: matrix.python == 'noPython' && matrix.os != 'windows-latest' - - - name: Upload VS code logs - uses: actions/upload-artifact@v4 - if: failure() - with: - name: VSCodeLogs-${{matrix.jupyterConnection}}-${{matrix.python}}-${{matrix.pythonVersion}}-${{matrix.packageVersion}}-${{matrix.os}}-${{env.TAGS_NAME}}-${{matrix.ipywidgetsVersion}} - path: '${{env.VSC_JUPYTER_USER_DATA_DIR}}/logs/**/*' - retention-days: 1 - - - name: Log test results - if: always() - run: npx gulp printTestResults - - - name: Upload test result, screenshots files - uses: actions/upload-artifact@v4 - if: always() - with: - name: TestLogs-${{matrix.jupyterConnection}}-${{matrix.python}}-${{matrix.pythonVersion}}-${{matrix.packageVersion}}-${{matrix.os}}-${{env.TAGS_NAME}}-${{matrix.ipywidgetsVersion}} - path: './logs/*' - retention-days: 60 - - - name: Verify there are no unhandled errors - run: npx gulp verifyUnhandledErrors - - smoke-tests: - timeout-minutes: 30 - name: Smoke tests - # The value of runs-on is the OS of the current job (specified in the strategy matrix below) instead of being hardcoded. - runs-on: ${{ matrix.os }} - if: github.repository == 'microsoft/vscode-jupyter' - needs: [build-vsix] - env: - VSIX_NAME: 'ms-toolsai-jupyter-insiders.vsix' - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python: ['3.10'] - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Use Python ${{matrix.python}} - uses: actions/setup-python@v5 - id: setupPythonVersion - with: - python-version: ${{matrix.python}} - - - name: Set CI Path - shell: bash - run: echo "CI_PYTHON_PATH=${{ steps.setupPythonVersion.outputs.python-path }}" >> $GITHUB_ENV - - - name: Use Node ${{env.NODE_VERSION}} - uses: actions/setup-node@v4 - with: - node-version: ${{env.NODE_VERSION}} - - - name: Use Npm ${{env.NPM_VERSION}} - run: npm i -g npm@${{env.NPM_VERSION}} - - - name: Download VSIX - uses: actions/download-artifact@v5 - with: - name: 'ms-toolsai-jupyter-insiders.vsix' - - # This is faster than running `npm ci`, we do not want to build zmq, etc. - # Let that happen in other jobs, this job needs to be fast - - name: npm ci - run: npm ci --ignore-scripts --prefer-offline --no-audit - - # Run again, as the download of zmq binaries could have failed with 403 errors - - run: npm run postinstall - env: - GITHUB_TOKEN: ${{ github.token }} - shell: bash - - - name: pip install system test requirements - run: | - python -m pip install --upgrade -r build/venv-smoke-test-requirements.txt - shell: bash - - # Compile the test files. - - name: Prepare for smoke tests - run: npx tsc -p ./ - shell: bash - - - name: Run desktop smoke tests - env: - DISPLAY: 10 - VSC_JUPYTER_FORCE_LOGGING: 1 - VSC_JUPYTER_CI_TEST_VSC_CHANNEL: 'insiders' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 - with: - run: npm run test:smoke diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..0e58537f75 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +env: + NODE_VERSION: 22.x + NPM_VERSION: 10.x + +permissions: + contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit + + - name: Run postinstall + run: npm run postinstall + + - name: Run ESLint + run: npm run lint + + - name: Check Prettier formatting + run: npm run format + + build: + name: Build & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit + + - name: Run postinstall + run: npm run postinstall + + - name: Compile TypeScript + run: npm run compile + + - name: Run tests + run: npm test + + - name: Check dependencies + run: npm run checkDependencies + continue-on-error: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 7d0a0c213a..0000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,75 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: 'CodeQL' - -#permissions: -# pull-requests: write - -permissions: {} - -on: - push: - branches: [main, release*] - pull_request: - # The branches below must be a subset of the branches above - branches: [main] - schedule: - - cron: '43 20 * * 4' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - # CPP is skipped for now because it only runs on windows - language: ['javascript', 'python'] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - persist-credentials: false - - # Initializes the CodeQL tools for scanning. - # - name: Initialize CodeQL - # uses: github/codeql-action/init@v1 - # with: - # languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - # - name: Autobuild - # uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - # - name: Perform CodeQL Analysis - # uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/deps.yml b/.github/workflows/deps.yml new file mode 100644 index 0000000000..97b45fb8d6 --- /dev/null +++ b/.github/workflows/deps.yml @@ -0,0 +1,66 @@ +name: Dependency Check + +on: + schedule: + # Run every Monday at 9 AM UTC + - cron: '0 9 * * 1' + workflow_dispatch: + +env: + NODE_VERSION: 22.x + +permissions: + contents: read + actions: read + +jobs: + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit + + - name: Run security audit + run: npm audit --json > audit-report.json || true + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-audit-report + path: audit-report.json + + - name: Check for outdated packages + run: npm outdated || true + + check-deps: + name: Dependency Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit + + - name: Run postinstall + run: npm run postinstall + + - name: Check dependencies for issues + run: npm run checkDependencies diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml deleted file mode 100644 index 1d66eeb88e..0000000000 --- a/.github/workflows/lock.yml +++ /dev/null @@ -1,27 +0,0 @@ -# https://github.com/marketplace/actions/lock-threads -name: 'Lock Threads' - -on: - schedule: - - cron: '0 0 * * *' - -permissions: - issues: write - -jobs: - lock: - runs-on: ubuntu-latest - steps: - - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 - id: lock - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - issue-inactive-days: '45' - issue-lock-reason: 'resolved' - process-only: 'issues' - - - name: Print locked issues - run: | - echo "Locked issues: ${ISSUES}" - env: - ISSUES: ${{ steps.lock.outputs.issues }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..9069d8ec98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +## Code Style & Organization +- Order method, fields and properties, first by accessibility and then by alphabetical order. +- Don't add the Microsoft copyright header to new files. + +## Testing +- Unit tests use Mocha/Chai framework with `.unit.test.ts` extension +- Test files should be placed alongside the source files they test +- Run all tests: `npm test` or `npm run test:unittests` +- Run single test file: `npx mocha --config ./build/.mocha.unittests.js.json ./out/path/to/file.unit.test.js` +- Tests run against compiled JavaScript files in `out/` directory +- Use `assert.deepStrictEqual()` for object comparisons instead of checking individual properties + +## Project Structure +- VSCode extension for Jupyter notebooks +- Uses dependency injection with inversify +- Follows separation of concerns pattern +- TypeScript codebase that compiles to `out/` directory + +## Deepnote Integration +- Located in `src/notebooks/deepnote/` +- Refactored architecture: + - `deepnoteTypes.ts` - Type definitions + - `deepnoteNotebookManager.ts` - State management + - `deepnoteNotebookSelector.ts` - UI selection logic + - `deepnoteDataConverter.ts` - Data transformations + - `deepnoteSerializer.ts` - Main serializer (orchestration) + - `deepnoteActivationService.ts` - VSCode activation +- Whitespace is good for readability, add a blank line after const groups and before return statements +- Separate third-party and local file imports \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9454cd050c..31485510a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "inversify": "^6.0.1", "isomorphic-ws": "^4.0.1", "jquery": "^3.6.0", + "js-yaml": "^4.1.0", "jsonc-parser": "^2.0.3", "lodash": "^4.17.21", "marked": "^4.0.10", @@ -92,6 +93,7 @@ "@types/fs-extra": "^5.0.1", "@types/get-port": "^3.2.0", "@types/glob": "^5.0.35", + "@types/js-yaml": "^4.0.9", "@types/json2csv": "^5.0.3", "@types/loadable__component": "^5.10.0", "@types/lodash": "^4.14.104", @@ -200,7 +202,7 @@ "util": "^0.12.4" }, "engines": { - "vscode": "^1.104.0" + "vscode": "^1.103.0" }, "optionalDependencies": { "fsevents": "^2.3.2" @@ -1277,12 +1279,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.23.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", @@ -1298,18 +1294,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -1501,6 +1485,16 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1514,6 +1508,20 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -2956,6 +2964,13 @@ "@types/sizzle": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", @@ -3622,13 +3637,6 @@ "vscode-test": "out/bin.mjs" } }, - "node_modules/@vscode/test-cli/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/@vscode/test-cli/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3700,19 +3708,6 @@ "node": ">=8" } }, - "node_modules/@vscode/test-cli/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@vscode/test-cli/node_modules/minimatch": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", @@ -4235,13 +4230,10 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/argv": { "version": "0.0.2", @@ -5674,6 +5666,30 @@ "node": ">=4.0" } }, + "node_modules/codecov/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/codecov/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -7951,12 +7967,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8058,18 +8068,6 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10826,13 +10824,12 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -11915,12 +11912,6 @@ "mocha": ">=3.1.2" } }, - "node_modules/mocha/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/mocha/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -12022,18 +12013,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/mocha/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/mocha/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -15040,8 +15019,9 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/stack-chain": { "version": "1.3.7", @@ -15943,6 +15923,17 @@ "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev" } }, + "node_modules/tslint/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/tslint/node_modules/builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", @@ -15963,6 +15954,21 @@ "node": ">=0.3.1" } }, + "node_modules/tslint/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/tslint/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -17779,12 +17785,6 @@ "strip-json-comments": "^3.1.1" }, "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "globals": { "version": "13.23.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", @@ -17794,15 +17794,6 @@ "type-fest": "^0.20.2" } }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -17933,6 +17924,15 @@ "resolve-from": "^5.0.0" }, "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -17943,6 +17943,16 @@ "path-exists": "^4.0.0" } }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -19221,6 +19231,12 @@ "@types/sizzle": "*" } }, + "@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, "@types/json-schema": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", @@ -19743,12 +19759,6 @@ "yargs": "^17.7.2" }, "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -19793,15 +19803,6 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, "minimatch": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", @@ -20177,13 +20178,9 @@ "dev": true }, "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "argv": { "version": "0.0.2", @@ -21249,6 +21246,27 @@ "js-yaml": "3.14.1", "teeny-request": "7.1.1", "urlgrey": "1.0.0" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } } }, "color-convert": { @@ -22711,12 +22729,6 @@ "color-convert": "^2.0.1" } }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -22785,15 +22797,6 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -25124,13 +25127,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" } }, "jsdoc-type-pratt-parser": { @@ -25944,12 +25945,6 @@ "yargs-unparser": "^2.0.0" }, "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -26016,15 +26011,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, "minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -28347,7 +28333,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, "stack-chain": { @@ -29079,6 +29065,16 @@ "tsutils": "^2.29.0" }, "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "peer": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", @@ -29093,6 +29089,17 @@ "dev": true, "peer": true }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "peer": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", diff --git a/package.json b/package.json index 7912c4a0b9..07643b13b1 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "theme": "light" }, "engines": { - "vscode": "^1.104.0" + "vscode": "^1.103.0" }, "l10n": "./l10n", "extensionKind": [ @@ -51,15 +51,16 @@ "Visualization" ], "activationEvents": [ - "onLanguage:python", "onLanguage:jupyter", - "onNotebook:jupyter-notebook", - "onNotebook:interactive", - "onWebviewPanel:jupyter-variables", - "onWebviewPanel:jupyter", + "onLanguage:python", "onLanguageModelTool:configure_notebook", + "onLanguageModelTool:notebook_install_packages", "onLanguageModelTool:notebook_list_packages", - "onLanguageModelTool:notebook_install_packages" + "onNotebook:deepnote", + "onNotebook:interactive", + "onNotebook:jupyter-notebook", + "onWebviewPanel:jupyter-variables", + "onWebviewPanel:jupyter" ], "main": "./dist/extension.node.proxy.js", "browser": "./dist/extension.web.bundle.js", @@ -316,6 +317,12 @@ "title": "%jupyter.command.jupyter.viewOutput.title%", "category": "Jupyter" }, + { + "command": "jupyter.selectDeepnoteNotebook", + "title": "Select Notebook", + "category": "Deepnote", + "enablement": "notebookType == 'deepnote'" + }, { "command": "jupyter.notebookeditor.export", "title": "%DataScience.notebookExportAs%", @@ -884,6 +891,11 @@ "group": "navigation@2", "when": "notebookType == 'jupyter-notebook' && config.jupyter.showOutlineButtonInNotebookToolbar" }, + { + "command": "jupyter.selectDeepnoteNotebook", + "group": "navigation@2", + "when": "notebookType == 'deepnote'" + }, { "command": "jupyter.continueEditSessionInCodespace", "group": "navigation@3", @@ -1937,6 +1949,17 @@ ] } ], + "notebooks": [ + { + "type": "deepnote", + "displayName": "%deepnote.notebook.displayName%", + "selector": [ + { + "filenamePattern": "*.deepnote" + } + ] + } + ], "notebookPreload": [ { "type": "jupyter-notebook", @@ -1945,6 +1968,13 @@ ], "entrypoint": "./dist/webviews/webview-side/ipywidgetsKernel/ipywidgetsKernel.js" }, + { + "type": "deepnote", + "localResourceRoots": [ + "./temp" + ], + "entrypoint": "./dist/webviews/webview-side/ipywidgetsKernel/ipywidgetsKernel.js" + }, { "type": "interactive", "localResourceRoots": [ @@ -2272,6 +2302,7 @@ "inversify": "^6.0.1", "isomorphic-ws": "^4.0.1", "jquery": "^3.6.0", + "js-yaml": "^4.1.0", "jsonc-parser": "^2.0.3", "lodash": "^4.17.21", "marked": "^4.0.10", @@ -2329,6 +2360,7 @@ "@types/fs-extra": "^5.0.1", "@types/get-port": "^3.2.0", "@types/glob": "^5.0.35", + "@types/js-yaml": "^4.0.9", "@types/json2csv": "^5.0.3", "@types/loadable__component": "^5.10.0", "@types/lodash": "^4.14.104", diff --git a/package.nls.json b/package.nls.json index a499a01d89..4c00e8fbf1 100644 --- a/package.nls.json +++ b/package.nls.json @@ -243,5 +243,6 @@ "jupyter.languageModelTools.configure_notebook.displayName": "Configure Jupyter Notebook", "jupyter.languageModelTools.configure_notebook.userDescription": "Ensure Notebook is ready for use, such as running cells.", "jupyter.languageModelTools.notebook_list_packages.userDescription": "Lists Python packages available in the selected Notebook Kernel.", - "jupyter.languageModelTools.notebook_install_packages.userDescription": "Installs Python packages in the selected Notebook Kernel." + "jupyter.languageModelTools.notebook_install_packages.userDescription": "Installs Python packages in the selected Notebook Kernel.", + "deepnote.notebook.displayName": "Deepnote Notebook" } diff --git a/src/notebooks/deepnote/MimeTypeProcessor.ts b/src/notebooks/deepnote/MimeTypeProcessor.ts new file mode 100644 index 0000000000..e5bc915a45 --- /dev/null +++ b/src/notebooks/deepnote/MimeTypeProcessor.ts @@ -0,0 +1,169 @@ +import { NotebookCellOutputItem } from 'vscode'; +import { parseJsonSafely, convertBase64ToUint8Array } from './dataConversionUtils'; + +export interface MimeProcessor { + canHandle(mimeType: string): boolean; + processForDeepnote(content: unknown, mimeType: string): unknown; + processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null; +} + +/** + * Handles text-based MIME types + */ +export class TextMimeProcessor implements MimeProcessor { + private readonly supportedTypes = ['text/plain', 'text/html']; + + canHandle(mimeType: string): boolean { + return this.supportedTypes.includes(mimeType); + } + + processForDeepnote(content: unknown): unknown { + return typeof content === 'string' ? content : String(content); + } + + processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null { + if (mimeType === 'text/plain') { + return NotebookCellOutputItem.text(content as string); + } + if (mimeType === 'text/html') { + return NotebookCellOutputItem.text(content as string, 'text/html'); + } + return null; + } +} + +/** + * Handles image MIME types + */ +export class ImageMimeProcessor implements MimeProcessor { + canHandle(mimeType: string): boolean { + return mimeType.startsWith('image/'); + } + + processForDeepnote(content: unknown, mimeType: string): unknown { + if (content instanceof Uint8Array) { + const base64String = btoa(String.fromCharCode(...content)); + return `data:${mimeType};base64,${base64String}`; + } + return content; + } + + processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null { + try { + let uint8Array: Uint8Array; + + if (typeof content === 'string') { + uint8Array = convertBase64ToUint8Array(content); + } else if (content instanceof ArrayBuffer) { + uint8Array = new Uint8Array(content); + } else if (content instanceof Uint8Array) { + uint8Array = content; + } else { + return null; + } + + return new NotebookCellOutputItem(uint8Array, mimeType); + } catch { + return NotebookCellOutputItem.text(String(content), mimeType); + } + } +} + +/** + * Handles JSON MIME types + */ +export class JsonMimeProcessor implements MimeProcessor { + canHandle(mimeType: string): boolean { + return mimeType === 'application/json'; + } + + processForDeepnote(content: unknown): unknown { + if (typeof content === 'string') { + return parseJsonSafely(content); + } + return content; + } + + processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null { + try { + let jsonObject: unknown; + + if (typeof content === 'string') { + jsonObject = JSON.parse(content); + } else if (typeof content === 'object' && content !== null) { + jsonObject = content; + } else { + return NotebookCellOutputItem.text(String(content), mimeType); + } + + return NotebookCellOutputItem.text(JSON.stringify(jsonObject, null, 2), mimeType); + } catch { + return NotebookCellOutputItem.text(String(content), mimeType); + } + } +} + +/** + * Handles other application MIME types + */ +export class ApplicationMimeProcessor implements MimeProcessor { + canHandle(mimeType: string): boolean { + return mimeType.startsWith('application/') && mimeType !== 'application/json'; + } + + processForDeepnote(content: unknown): unknown { + if (typeof content === 'string') { + return parseJsonSafely(content); + } + return content; + } + + processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null { + const textContent = typeof content === 'string' ? content : JSON.stringify(content, null, 2); + return NotebookCellOutputItem.text(textContent, mimeType); + } +} + +/** + * Generic fallback processor + */ +export class GenericMimeProcessor implements MimeProcessor { + canHandle(): boolean { + return true; // Always can handle as fallback + } + + processForDeepnote(content: unknown): unknown { + return content; + } + + processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null { + return NotebookCellOutputItem.text(String(content), mimeType); + } +} + +/** + * Registry for MIME type processors + */ +export class MimeTypeProcessorRegistry { + private readonly processors: MimeProcessor[] = [ + new TextMimeProcessor(), + new ImageMimeProcessor(), + new JsonMimeProcessor(), + new ApplicationMimeProcessor(), + new GenericMimeProcessor() // Must be last as fallback + ]; + + getProcessor(mimeType: string): MimeProcessor { + return this.processors.find((processor) => processor.canHandle(mimeType)) || new GenericMimeProcessor(); + } + + processForDeepnote(content: unknown, mimeType: string): unknown { + const processor = this.getProcessor(mimeType); + return processor.processForDeepnote(content, mimeType); + } + + processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null { + const processor = this.getProcessor(mimeType); + return processor.processForVSCode(content, mimeType); + } +} diff --git a/src/notebooks/deepnote/OutputTypeDetector.ts b/src/notebooks/deepnote/OutputTypeDetector.ts new file mode 100644 index 0000000000..94d36515fb --- /dev/null +++ b/src/notebooks/deepnote/OutputTypeDetector.ts @@ -0,0 +1,51 @@ +import { NotebookCellOutput } from 'vscode'; + +export type DetectedOutputType = 'error' | 'stream' | 'rich'; + +export interface OutputTypeResult { + type: DetectedOutputType; + streamMimes?: string[]; + errorItem?: { mime: string; data: Uint8Array }; +} + +/** + * Detects the appropriate output type from VS Code NotebookCellOutput items + */ +export class OutputTypeDetector { + private readonly streamMimes = [ + 'text/plain', + 'application/vnd.code.notebook.stdout', + 'application/vnd.code.notebook.stderr' + ]; + + detect(output: NotebookCellOutput): OutputTypeResult { + if (output.items.length === 0) { + return { type: 'rich' }; + } + + // Check for error output first + const errorItem = output.items.find((item) => item.mime === 'application/vnd.code.notebook.error'); + if (errorItem) { + return { + type: 'error', + errorItem: { mime: errorItem.mime, data: errorItem.data } + }; + } + + // Check for stream outputs + const streamItems = output.items.filter((item) => this.streamMimes.includes(item.mime)); + if (streamItems.length > 0) { + return { + type: 'stream', + streamMimes: streamItems.map((item) => item.mime) + }; + } + + // Default to rich output + return { type: 'rich' }; + } + + isStreamMime(mimeType: string): boolean { + return this.streamMimes.includes(mimeType); + } +} diff --git a/src/notebooks/deepnote/dataConversionUtils.ts b/src/notebooks/deepnote/dataConversionUtils.ts new file mode 100644 index 0000000000..cd21fa8770 --- /dev/null +++ b/src/notebooks/deepnote/dataConversionUtils.ts @@ -0,0 +1,91 @@ +/** + * Utility functions for data transformation in Deepnote conversion + */ + +/** + * Safely decode content using TextDecoder + */ +export function decodeContent(data: Uint8Array): string { + return new TextDecoder().decode(data); +} + +/** + * Safely parse JSON with fallback to original content + */ +export function parseJsonSafely(content: string): unknown { + try { + return JSON.parse(content); + } catch { + return content; + } +} + +/** + * Convert base64 string to Uint8Array + */ +export function convertBase64ToUint8Array(base64Content: string): Uint8Array { + const base64Data = base64Content.includes(',') ? base64Content.split(',')[1] : base64Content; + const binaryString = atob(base64Data); + const uint8Array = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + uint8Array[i] = binaryString.charCodeAt(i); + } + return uint8Array; +} + +/** + * Convert Uint8Array to base64 data URL + */ +export function convertUint8ArrayToBase64DataUrl(data: Uint8Array, mimeType: string): string { + const base64String = btoa(String.fromCharCode(...data)); + return `data:${mimeType};base64,${base64String}`; +} + +/** + * Merge metadata objects, filtering out undefined values + */ +export function mergeMetadata(...metadataObjects: (Record | undefined)[]): Record { + const result: Record = {}; + + for (const metadata of metadataObjects) { + if (metadata) { + Object.entries(metadata).forEach(([key, value]) => { + if (value !== undefined) { + result[key] = value; + } + }); + } + } + + return result; +} + +/** + * Check if metadata object has any content + */ +export function hasMetadataContent(metadata: Record): boolean { + return Object.keys(metadata).length > 0; +} + +/** + * Generate a random hex ID for blocks + */ +export function generateBlockId(): string { + const chars = '0123456789abcdef'; + let id = ''; + for (let i = 0; i < 32; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +/** + * Generate sorting key based on index + */ +export function generateSortingKey(index: number): string { + const alphabet = 'abcdefghijklmnopqrstuvwxyz'; + const letterIndex = Math.floor(index / 100); + const letter = letterIndex < alphabet.length ? alphabet[letterIndex] : 'z'; + const number = index % 100; + return `${letter}${number}`; +} diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts new file mode 100644 index 0000000000..568faf478e --- /dev/null +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -0,0 +1,193 @@ +import { injectable, inject } from 'inversify'; +import { workspace, commands, window, WorkspaceEdit, NotebookEdit, NotebookRange, l10n, Uri } from 'vscode'; +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IExtensionContext } from '../../platform/common/types'; +import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; +import type { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes'; +import { DeepnoteNotebookSelector } from './deepnoteNotebookSelector'; +import { Commands } from '../../platform/common/constants'; + +/** + * Service responsible for activating and configuring Deepnote notebook support in VS Code. + * Registers serializers, command handlers, and manages the notebook selection workflow. + */ +@injectable() +export class DeepnoteActivationService implements IExtensionSyncActivationService { + private serializer: DeepnoteNotebookSerializer; + private selector: DeepnoteNotebookSelector; + + constructor(@inject(IExtensionContext) private extensionContext: IExtensionContext) {} + + /** + * Activates Deepnote support by registering serializers and commands. + * Called during extension activation to set up Deepnote integration. + */ + public activate() { + this.serializer = new DeepnoteNotebookSerializer(); + this.selector = new DeepnoteNotebookSelector(); + + // Set up the custom notebook selection callback + this.serializer.setNotebookSelectionCallback(this.handleNotebookSelection.bind(this)); + + this.extensionContext.subscriptions.push(workspace.registerNotebookSerializer('deepnote', this.serializer)); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.SelectDeepnoteNotebook, () => this.selectNotebook(this.selector)) + ); + } + + private async getDeepnoteProject(notebookUri: Uri, projectId?: string): Promise { + // Try cache first if we have a project ID + if (projectId) { + const cachedProject = this.serializer.getManager().getOriginalProject(projectId); + if (cachedProject) { + return cachedProject; + } + } + + // Cache miss or no project ID - read and parse file + const rawContent = await workspace.fs.readFile(notebookUri); + const contentString = Buffer.from(rawContent).toString('utf8'); + const yaml = await import('js-yaml'); + const deepnoteProject = yaml.load(contentString) as DeepnoteProject; + + // Store in cache if we have a project ID + if (projectId && deepnoteProject) { + const manager = this.serializer.getManager(); + const currentNotebookId = manager.getCurrentNotebookId(projectId); + if (currentNotebookId) { + manager.storeOriginalProject(projectId, deepnoteProject, currentNotebookId); + } + } + + return deepnoteProject; + } + + private async selectNotebook(selector: DeepnoteNotebookSelector) { + const activeEditor = window.activeNotebookEditor; + + if (!activeEditor || activeEditor.notebook.notebookType !== 'deepnote') { + await window.showErrorMessage(l10n.t('Please open a Deepnote file first.')); + return; + } + + const notebookUri = activeEditor.notebook.uri; + const projectId = activeEditor.notebook.metadata?.deepnoteProjectId; + + try { + const deepnoteProject = await this.getDeepnoteProject(notebookUri, projectId); + + if (!deepnoteProject?.project?.notebooks) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file: No notebooks found.')); + return; + } + + if (deepnoteProject.project.notebooks.length === 1) { + await window.showInformationMessage(l10n.t('This Deepnote file contains only one notebook.')); + + return; + } + + const currentNotebookId = activeEditor.notebook.metadata?.deepnoteNotebookId; + + const selectedNotebook = await selector.selectNotebook( + deepnoteProject.project.notebooks, + currentNotebookId, + { + placeHolder: l10n.t('Select a notebook to switch to'), + title: l10n.t('Switch Notebook') + } + ); + + if (selectedNotebook && selectedNotebook.id !== currentNotebookId) { + // Create new cells from the selected notebook + const converter = this.serializer.getConverter(); + const cells = converter.convertBlocksToCells(selectedNotebook.blocks); + + // Create a workspace edit to replace all cells + const edit = new WorkspaceEdit(); + const notebookEdit = NotebookEdit.replaceCells( + new NotebookRange(0, activeEditor.notebook.cellCount), + cells + ); + + // Also update metadata to reflect the new notebook + const metadataEdit = NotebookEdit.updateNotebookMetadata({ + ...activeEditor.notebook.metadata, + deepnoteNotebookId: selectedNotebook.id, + deepnoteNotebookName: selectedNotebook.name + }); + + edit.set(notebookUri, [notebookEdit, metadataEdit]); + + // Apply the edit + const success = await workspace.applyEdit(edit); + + if (success) { + // Store the selected notebook ID for future reference + const fileUri = notebookUri.toString(); + const projectId = deepnoteProject.project.id; + const manager = this.serializer.getManager(); + manager.setSelectedNotebookForUri(fileUri, selectedNotebook.id); + + // Update the current notebook ID for serialization + manager.storeOriginalProject( + projectId, + manager.getOriginalProject(projectId) || deepnoteProject, + selectedNotebook.id + ); + + await window.showInformationMessage(l10n.t('Switched to notebook: {0}', selectedNotebook.name)); + } else { + await window.showErrorMessage(l10n.t('Failed to switch notebook.')); + } + } + } catch (error) { + await window.showErrorMessage( + l10n.t( + 'Error switching notebook: {0}', + error instanceof Error ? error.message : l10n.t('Unknown error') + ) + ); + } + } + + private async handleNotebookSelection( + projectId: string, + notebooks: DeepnoteNotebook[] + ): Promise { + const manager = this.serializer.getManager(); + const fileId = projectId; + const skipPrompt = manager.shouldSkipPrompt(fileId); + const storedNotebookId = manager.getSelectedNotebookForUri(fileId); + + if (notebooks.length === 1) { + return notebooks[0]; + } + + if (skipPrompt && storedNotebookId) { + // Use the stored selection when triggered by command + const preSelected = notebooks.find((nb) => nb.id === storedNotebookId); + return preSelected || notebooks[0]; + } + + if (storedNotebookId && !skipPrompt) { + // Normal file open - check if we have a previously selected notebook + const preSelected = notebooks.find((nb) => nb.id === storedNotebookId); + if (preSelected) { + return preSelected; + } + // Previously selected notebook not found, prompt for selection + } + + // Prompt user to select a notebook + const selected = await this.selector.selectNotebook(notebooks); + if (selected) { + manager.setSelectedNotebookForUri(fileId, selected.id); + return selected; + } + + // If user cancelled selection, default to the first notebook + return notebooks[0]; + } +} diff --git a/src/notebooks/deepnote/deepnoteDataConverter.ts b/src/notebooks/deepnote/deepnoteDataConverter.ts new file mode 100644 index 0000000000..59f640dfff --- /dev/null +++ b/src/notebooks/deepnote/deepnoteDataConverter.ts @@ -0,0 +1,162 @@ +import { NotebookCellData, NotebookCellKind, NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; + +import type { DeepnoteBlock, DeepnoteOutput } from './deepnoteTypes'; +import { OutputTypeDetector } from './OutputTypeDetector'; +import { StreamOutputHandler } from './outputHandlers/StreamOutputHandler'; +import { ErrorOutputHandler } from './outputHandlers/ErrorOutputHandler'; +import { RichOutputHandler } from './outputHandlers/RichOutputHandler'; +import { mergeMetadata, hasMetadataContent, generateBlockId, generateSortingKey } from './dataConversionUtils'; + +/** + * Utility class for converting between Deepnote block structures and VS Code notebook cells. + * Handles bidirectional conversion while preserving metadata and execution state. + */ +export class DeepnoteDataConverter { + private readonly outputDetector = new OutputTypeDetector(); + private readonly streamHandler = new StreamOutputHandler(); + private readonly errorHandler = new ErrorOutputHandler(); + private readonly richHandler = new RichOutputHandler(); + + /** + * Converts Deepnote blocks to VS Code notebook cells. + * Sorts blocks by sortingKey before conversion to maintain proper order. + * @param blocks Array of Deepnote blocks to convert + * @returns Array of VS Code notebook cell data + */ + convertBlocksToCells(blocks: DeepnoteBlock[]): NotebookCellData[] { + return blocks + .sort((a, b) => a.sortingKey.localeCompare(b.sortingKey)) + .map((block) => this.convertBlockToCell(block)); + } + + /** + * Converts VS Code notebook cells back to Deepnote blocks. + * Generates missing IDs and sorting keys as needed. + * @param cells Array of VS Code notebook cells to convert + * @returns Array of Deepnote blocks + */ + convertCellsToBlocks(cells: NotebookCellData[]): DeepnoteBlock[] { + return cells.map((cell, index) => this.convertCellToBlock(cell, index)); + } + + private convertBlockToCell(block: DeepnoteBlock): NotebookCellData { + const cellKind = block.type === 'code' ? NotebookCellKind.Code : NotebookCellKind.Markup; + const languageId = block.type === 'code' ? 'python' : 'markdown'; + + const cell = new NotebookCellData(cellKind, block.content, languageId); + + cell.metadata = { + deepnoteBlockId: block.id, + deepnoteBlockType: block.type, + deepnoteSortingKey: block.sortingKey, + deepnoteMetadata: block.metadata, + ...(typeof block.executionCount === 'number' && { executionCount: block.executionCount }), + ...(block.outputReference && { deepnoteOutputReference: block.outputReference }) + }; + + cell.outputs = this.convertDeepnoteOutputsToVSCodeOutputs(block.outputs || []); + + return cell; + } + + private convertCellToBlock(cell: NotebookCellData, index: number): DeepnoteBlock { + const blockId = cell.metadata?.deepnoteBlockId || generateBlockId(); + const sortingKey = cell.metadata?.deepnoteSortingKey || generateSortingKey(index); + const originalMetadata = cell.metadata?.deepnoteMetadata || {}; + + const block: DeepnoteBlock = { + id: blockId, + sortingKey: sortingKey, + type: cell.kind === NotebookCellKind.Code ? 'code' : 'markdown', + content: cell.value, + metadata: originalMetadata + }; + + if (cell.kind === NotebookCellKind.Code) { + const executionCount = cell.metadata?.executionCount || cell.executionSummary?.executionOrder; + if (executionCount !== undefined) { + block.executionCount = executionCount; + } + } + + if (cell.metadata?.deepnoteOutputReference) { + block.outputReference = cell.metadata.deepnoteOutputReference; + } + + if (cell.outputs && cell.outputs.length > 0) { + block.outputs = this.convertVSCodeOutputsToDeepnoteOutputs(cell.outputs); + } + + return block; + } + + private convertDeepnoteOutputsToVSCodeOutputs(deepnoteOutputs: DeepnoteOutput[]): NotebookCellOutput[] { + return deepnoteOutputs.map((output) => this.convertSingleOutput(output)); + } + + private convertSingleOutput(output: DeepnoteOutput): NotebookCellOutput { + const outputItems = this.createOutputItems(output); + + const metadata = mergeMetadata( + output.metadata, + output.execution_count !== undefined ? { executionCount: output.execution_count } : undefined + ); + + return hasMetadataContent(metadata) + ? new NotebookCellOutput(outputItems, metadata) + : new NotebookCellOutput(outputItems); + } + + private convertVSCodeOutputsToDeepnoteOutputs(vscodeOutputs: NotebookCellOutput[]): DeepnoteOutput[] { + return vscodeOutputs.map((output) => this.convertVSCodeSingleOutput(output)); + } + + private convertVSCodeSingleOutput(output: NotebookCellOutput): DeepnoteOutput { + // Detect output type and delegate to appropriate handler + const detection = this.outputDetector.detect(output); + let deepnoteOutput: DeepnoteOutput; + + switch (detection.type) { + case 'error': + deepnoteOutput = this.errorHandler.convertToDeepnote(detection.errorItem!); + break; + case 'stream': + deepnoteOutput = this.streamHandler.convertToDeepnote(output); + break; + case 'rich': + default: + deepnoteOutput = this.richHandler.convertToDeepnote(output); + break; + } + + // Preserve metadata from VS Code output + if (output.metadata) { + deepnoteOutput.metadata = mergeMetadata(deepnoteOutput.metadata, output.metadata); + + // Extract execution count from metadata + if (output.metadata.executionCount !== undefined) { + deepnoteOutput.execution_count = output.metadata.executionCount as number; + } + } + + return deepnoteOutput; + } + + private createOutputItems(output: DeepnoteOutput): NotebookCellOutputItem[] { + switch (output.output_type) { + case 'stream': + return this.streamHandler.convertToVSCode(output); + case 'error': + return this.errorHandler.convertToVSCode(output); + case 'execute_result': + case 'display_data': + return this.richHandler.convertToVSCode(output); + default: + // Fallback for unknown types with text + if (output.text) { + return [NotebookCellOutputItem.text(output.text)]; + } + return []; + } + } +} diff --git a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts new file mode 100644 index 0000000000..7d6d778135 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts @@ -0,0 +1,458 @@ +import { assert } from 'chai'; +import { NotebookCellKind, type NotebookCellData } from 'vscode'; + +import { DeepnoteDataConverter } from './deepnoteDataConverter'; +import type { DeepnoteBlock, DeepnoteOutput } from './deepnoteTypes'; + +suite('DeepnoteDataConverter', () => { + let converter: DeepnoteDataConverter; + + setup(() => { + converter = new DeepnoteDataConverter(); + }); + + suite('convertBlocksToCells', () => { + test('converts simple code block to cell', () => { + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'print("hello")', + sortingKey: 'a0', + metadata: { custom: 'data' } + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + + assert.strictEqual(cells.length, 1); + assert.strictEqual(cells[0].kind, NotebookCellKind.Code); + assert.strictEqual(cells[0].value, 'print("hello")'); + assert.strictEqual(cells[0].languageId, 'python'); + assert.strictEqual(cells[0].metadata?.deepnoteBlockId, 'block1'); + assert.strictEqual(cells[0].metadata?.deepnoteBlockType, 'code'); + assert.strictEqual(cells[0].metadata?.deepnoteSortingKey, 'a0'); + assert.deepStrictEqual(cells[0].metadata?.deepnoteMetadata, { custom: 'data' }); + }); + + test('converts simple markdown block to cell', () => { + const blocks: DeepnoteBlock[] = [ + { + id: 'block2', + type: 'markdown', + content: '# Title', + sortingKey: 'a1' + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + + assert.strictEqual(cells.length, 1); + assert.strictEqual(cells[0].kind, NotebookCellKind.Markup); + assert.strictEqual(cells[0].value, '# Title'); + assert.strictEqual(cells[0].languageId, 'markdown'); + assert.strictEqual(cells[0].metadata?.deepnoteBlockId, 'block2'); + assert.strictEqual(cells[0].metadata?.deepnoteBlockType, 'markdown'); + }); + + test('sorts blocks by sortingKey', () => { + const blocks: DeepnoteBlock[] = [ + { + id: 'block2', + type: 'code', + content: 'second', + sortingKey: 'b0' + }, + { + id: 'block1', + type: 'code', + content: 'first', + sortingKey: 'a0' + }, + { + id: 'block3', + type: 'code', + content: 'third', + sortingKey: 'c0' + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + + assert.strictEqual(cells.length, 3); + assert.strictEqual(cells[0].value, 'first'); + assert.strictEqual(cells[1].value, 'second'); + assert.strictEqual(cells[2].value, 'third'); + }); + + test('handles execution count and output reference', () => { + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'x = 1', + sortingKey: 'a0', + executionCount: 5, + outputReference: 'output-ref-123' + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + + assert.strictEqual(cells[0].metadata?.executionCount, 5); + assert.strictEqual(cells[0].metadata?.deepnoteOutputReference, 'output-ref-123'); + }); + + test('converts blocks with outputs', () => { + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'print("hello")', + sortingKey: 'a0', + outputs: [ + { + output_type: 'stream', + text: 'hello\n' + } + ] + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + + assert.strictEqual(cells[0].outputs?.length, 1); + assert.strictEqual(cells[0].outputs?.[0].items.length, 1); + assert.strictEqual(cells[0].outputs?.[0].items[0].mime, 'application/vnd.code.notebook.stdout'); + }); + }); + + suite('convertCellsToBlocks', () => { + test('converts code cell to block', () => { + const cells: NotebookCellData[] = [ + { + kind: NotebookCellKind.Code, + value: 'print("test")', + languageId: 'python', + metadata: { + deepnoteBlockId: 'existing-id', + deepnoteSortingKey: 'a5', + deepnoteMetadata: { original: 'metadata' } + } + } + ]; + + const blocks = converter.convertCellsToBlocks(cells); + + assert.strictEqual(blocks.length, 1); + assert.strictEqual(blocks[0].type, 'code'); + assert.strictEqual(blocks[0].content, 'print("test")'); + assert.strictEqual(blocks[0].id, 'existing-id'); + assert.strictEqual(blocks[0].sortingKey, 'a5'); + assert.deepStrictEqual(blocks[0].metadata, { original: 'metadata' }); + }); + + test('converts markdown cell to block', () => { + const cells: NotebookCellData[] = [ + { + kind: NotebookCellKind.Markup, + value: '## Heading', + languageId: 'markdown' + } + ]; + + const blocks = converter.convertCellsToBlocks(cells); + + assert.strictEqual(blocks[0].type, 'markdown'); + assert.strictEqual(blocks[0].content, '## Heading'); + }); + + test('generates new IDs and sorting keys for cells without metadata', () => { + const cells: NotebookCellData[] = [ + { + kind: NotebookCellKind.Code, + value: 'x = 1', + languageId: 'python' + }, + { + kind: NotebookCellKind.Code, + value: 'y = 2', + languageId: 'python' + } + ]; + + const blocks = converter.convertCellsToBlocks(cells); + + assert.strictEqual(blocks.length, 2); + assert.match(blocks[0].id, /^[0-9a-f]{32}$/); + assert.match(blocks[1].id, /^[0-9a-f]{32}$/); + assert.notStrictEqual(blocks[0].id, blocks[1].id); + assert.strictEqual(blocks[0].sortingKey, 'a0'); + assert.strictEqual(blocks[1].sortingKey, 'a1'); + }); + + test('handles execution count from metadata and executionSummary', () => { + const cells: NotebookCellData[] = [ + { + kind: NotebookCellKind.Code, + value: 'x = 1', + languageId: 'python', + metadata: { executionCount: 10 } + }, + { + kind: NotebookCellKind.Code, + value: 'y = 2', + languageId: 'python', + executionSummary: { executionOrder: 20 } + } + ]; + + const blocks = converter.convertCellsToBlocks(cells); + + assert.strictEqual(blocks[0].executionCount, 10); + assert.strictEqual(blocks[1].executionCount, 20); + }); + + test('includes output reference when present', () => { + const cells: NotebookCellData[] = [ + { + kind: NotebookCellKind.Code, + value: 'print("test")', + languageId: 'python', + metadata: { deepnoteOutputReference: 'ref-123' } + } + ]; + + const blocks = converter.convertCellsToBlocks(cells); + + assert.strictEqual(blocks[0].outputReference, 'ref-123'); + }); + }); + + suite('output conversion', () => { + test('converts stream output', () => { + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'stream', + text: 'Hello world\n' + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'print("Hello world")', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 1); + assert.strictEqual(outputs[0].items[0].mime, 'application/vnd.code.notebook.stdout'); + assert.strictEqual(new TextDecoder().decode(outputs[0].items[0].data), 'Hello world\n'); + }); + + test('converts error output', () => { + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'error', + text: "NameError: name 'x' is not defined" + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'print(x)', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 1); + assert.strictEqual(outputs[0].items[0].mime, 'application/vnd.code.notebook.error'); + }); + + test('converts execute_result with multiple mime types', () => { + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'execute_result', + execution_count: 1, + data: { + 'text/plain': '42', + 'text/html': '
42
', + 'application/json': { value: 42 } + }, + metadata: { custom: 'metadata' } + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'x', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 3); + assert.strictEqual(outputs[0].metadata?.executionCount, 1); + assert.strictEqual(outputs[0].metadata?.custom, 'metadata'); + + const mimeTypes = outputs[0].items.map((item) => item.mime).sort(); + assert.deepStrictEqual(mimeTypes, ['application/json', 'text/html', 'text/plain']); + }); + + test('handles empty outputs', () => { + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'execute_result' + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'None', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 0); + }); + + test('handles unknown output types with text fallback', () => { + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'unknown_type', + text: 'fallback text' + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'something', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 1); + assert.strictEqual(outputs[0].items[0].mime, 'text/plain'); + assert.strictEqual(new TextDecoder().decode(outputs[0].items[0].data), 'fallback text'); + }); + + test('handles stream output without text', () => { + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'stream' + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'print()', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 0); + }); + + test('handles rich output without data but with text fallback', () => { + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'execute_result', + text: 'fallback text' + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'result', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 1); + assert.strictEqual(outputs[0].items[0].mime, 'text/plain'); + assert.strictEqual(new TextDecoder().decode(outputs[0].items[0].data), 'fallback text'); + }); + }); + + suite('round trip conversion', () => { + test('blocks -> cells -> blocks preserves data', () => { + const originalBlocks: DeepnoteBlock[] = [ + { + id: 'block1', + type: 'code', + content: 'print("hello")', + sortingKey: 'a0', + executionCount: 5, + metadata: { custom: 'data' }, + outputReference: 'ref-123', + outputs: [ + { + output_type: 'stream', + text: 'hello\n' + } + ] + }, + { + id: 'block2', + type: 'markdown', + content: '# Title', + sortingKey: 'a1', + metadata: { another: 'value' } + } + ]; + + const cells = converter.convertBlocksToCells(originalBlocks); + const roundTripBlocks = converter.convertCellsToBlocks(cells); + + assert.deepStrictEqual(roundTripBlocks, originalBlocks); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts new file mode 100644 index 0000000000..a1c0d52b18 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -0,0 +1,91 @@ +import type { DeepnoteProject } from './deepnoteTypes'; + +/** + * Centralized manager for tracking Deepnote notebook selections and project state. + * Manages per-project and per-URI state including current selections and user preferences. + */ +export class DeepnoteNotebookManager { + private currentNotebookId = new Map(); + private originalProjects = new Map(); + private selectedNotebookByUri = new Map(); + private skipPromptForUri = new Set(); + + /** + * Gets the currently selected notebook ID for a project. + * @param projectId Project identifier + * @returns Current notebook ID or undefined if not set + */ + getCurrentNotebookId(projectId: string): string | undefined { + return this.currentNotebookId.get(projectId); + } + + /** + * Retrieves the original project data for a given project ID. + * @param projectId Project identifier + * @returns Original project data or undefined if not found + */ + getOriginalProject(projectId: string): DeepnoteProject | undefined { + return this.originalProjects.get(projectId); + } + + /** + * Gets the selected notebook ID for a specific file URI. + * @param uri File URI string + * @returns Selected notebook ID or undefined if not set + */ + getSelectedNotebookForUri(uri: string): string | undefined { + return this.selectedNotebookByUri.get(uri); + } + + /** + * Associates a notebook ID with a file URI to remember user's notebook selection. + * When a Deepnote file contains multiple notebooks, this mapping persists the user's + * choice so we can automatically open the same notebook on subsequent file opens. + * Also marks the URI to skip the selection prompt on the next immediate open. + * + * @param uri - The file URI (or project ID) that identifies the Deepnote file + * @param notebookId - The ID of the selected notebook within the file + */ + setSelectedNotebookForUri(uri: string, notebookId: string): void { + this.selectedNotebookByUri.set(uri, notebookId); + this.skipPromptForUri.add(uri); + } + + /** + * Checks if prompts should be skipped for a given URI and consumes the skip flag. + * This is used to avoid showing selection prompts immediately after a user makes a choice. + * @param uri File URI string + * @returns True if prompts should be skipped (and resets the flag) + */ + shouldSkipPrompt(uri: string): boolean { + if (this.skipPromptForUri.has(uri)) { + this.skipPromptForUri.delete(uri); + + return true; + } + + return false; + } + + /** + * Stores the original project data and sets the initial current notebook. + * This is used during deserialization to cache project data and track the active notebook. + * @param projectId Project identifier + * @param project Original project data to store + * @param notebookId Initial notebook ID to set as current + */ + storeOriginalProject(projectId: string, project: DeepnoteProject, notebookId: string): void { + this.originalProjects.set(projectId, project); + this.currentNotebookId.set(projectId, notebookId); + } + + /** + * Updates the current notebook ID for a project. + * Used when switching notebooks within the same project. + * @param projectId Project identifier + * @param notebookId New current notebook ID + */ + updateCurrentNotebookId(projectId: string, notebookId: string): void { + this.currentNotebookId.set(projectId, notebookId); + } +} diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts new file mode 100644 index 0000000000..50d30a4c92 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -0,0 +1,230 @@ +import * as assert from 'assert'; + +import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; +import type { DeepnoteProject } from './deepnoteTypes'; + +suite('DeepnoteNotebookManager', () => { + let manager: DeepnoteNotebookManager; + + const mockProject: DeepnoteProject = { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-123', + name: 'Test Project', + notebooks: [], + settings: {} + }, + version: '1.0' + }; + + setup(() => { + manager = new DeepnoteNotebookManager(); + }); + + suite('getCurrentNotebookId', () => { + test('should return undefined for unknown project', () => { + const result = manager.getCurrentNotebookId('unknown-project'); + + assert.strictEqual(result, undefined); + }); + + test('should return notebook ID after storing project', () => { + manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + + const result = manager.getCurrentNotebookId('project-123'); + + assert.strictEqual(result, 'notebook-456'); + }); + + test('should return updated notebook ID', () => { + manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.updateCurrentNotebookId('project-123', 'notebook-789'); + + const result = manager.getCurrentNotebookId('project-123'); + + assert.strictEqual(result, 'notebook-789'); + }); + }); + + suite('getOriginalProject', () => { + test('should return undefined for unknown project', () => { + const result = manager.getOriginalProject('unknown-project'); + + assert.strictEqual(result, undefined); + }); + + test('should return original project after storing', () => { + manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + + const result = manager.getOriginalProject('project-123'); + + assert.deepStrictEqual(result, mockProject); + }); + }); + + suite('getSelectedNotebookForUri', () => { + test('should return undefined for unknown URI', () => { + const result = manager.getSelectedNotebookForUri('file:///unknown.deepnote'); + + assert.strictEqual(result, undefined); + }); + + test('should return notebook ID after setting', () => { + manager.setSelectedNotebookForUri('file:///test.deepnote', 'notebook-456'); + + const result = manager.getSelectedNotebookForUri('file:///test.deepnote'); + + assert.strictEqual(result, 'notebook-456'); + }); + }); + + suite('setSelectedNotebookForUri', () => { + test('should store notebook selection and mark for skip prompt', () => { + manager.setSelectedNotebookForUri('file:///test.deepnote', 'notebook-456'); + + const selectedNotebook = manager.getSelectedNotebookForUri('file:///test.deepnote'); + const shouldSkip = manager.shouldSkipPrompt('file:///test.deepnote'); + + assert.strictEqual(selectedNotebook, 'notebook-456'); + assert.strictEqual(shouldSkip, true); + }); + + test('should overwrite existing selection', () => { + manager.setSelectedNotebookForUri('file:///test.deepnote', 'notebook-456'); + manager.setSelectedNotebookForUri('file:///test.deepnote', 'notebook-789'); + + const result = manager.getSelectedNotebookForUri('file:///test.deepnote'); + + assert.strictEqual(result, 'notebook-789'); + }); + }); + + suite('shouldSkipPrompt', () => { + test('should return false for unknown URI', () => { + const result = manager.shouldSkipPrompt('file:///unknown.deepnote'); + + assert.strictEqual(result, false); + }); + + test('should return true and remove skip flag on first call', () => { + manager.setSelectedNotebookForUri('file:///test.deepnote', 'notebook-456'); + + const firstCall = manager.shouldSkipPrompt('file:///test.deepnote'); + const secondCall = manager.shouldSkipPrompt('file:///test.deepnote'); + + assert.strictEqual(firstCall, true); + assert.strictEqual(secondCall, false); + }); + + test('should handle multiple URIs independently', () => { + manager.setSelectedNotebookForUri('file:///test1.deepnote', 'notebook-1'); + manager.setSelectedNotebookForUri('file:///test2.deepnote', 'notebook-2'); + + const shouldSkip1 = manager.shouldSkipPrompt('file:///test1.deepnote'); + const shouldSkip2 = manager.shouldSkipPrompt('file:///test2.deepnote'); + + assert.strictEqual(shouldSkip1, true); + assert.strictEqual(shouldSkip2, true); + }); + }); + + suite('storeOriginalProject', () => { + test('should store both project and current notebook ID', () => { + manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + + const storedProject = manager.getOriginalProject('project-123'); + const currentNotebookId = manager.getCurrentNotebookId('project-123'); + + assert.deepStrictEqual(storedProject, mockProject); + assert.strictEqual(currentNotebookId, 'notebook-456'); + }); + + test('should overwrite existing project data', () => { + const updatedProject: DeepnoteProject = { + ...mockProject, + project: { + ...mockProject.project, + name: 'Updated Project' + } + }; + + manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', updatedProject, 'notebook-789'); + + const storedProject = manager.getOriginalProject('project-123'); + const currentNotebookId = manager.getCurrentNotebookId('project-123'); + + assert.deepStrictEqual(storedProject, updatedProject); + assert.strictEqual(currentNotebookId, 'notebook-789'); + }); + }); + + suite('updateCurrentNotebookId', () => { + test('should update notebook ID for existing project', () => { + manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.updateCurrentNotebookId('project-123', 'notebook-789'); + + const result = manager.getCurrentNotebookId('project-123'); + + assert.strictEqual(result, 'notebook-789'); + }); + + test('should set notebook ID for new project', () => { + manager.updateCurrentNotebookId('new-project', 'notebook-123'); + + const result = manager.getCurrentNotebookId('new-project'); + + assert.strictEqual(result, 'notebook-123'); + }); + + test('should handle multiple projects independently', () => { + manager.updateCurrentNotebookId('project-1', 'notebook-1'); + manager.updateCurrentNotebookId('project-2', 'notebook-2'); + + const result1 = manager.getCurrentNotebookId('project-1'); + const result2 = manager.getCurrentNotebookId('project-2'); + + assert.strictEqual(result1, 'notebook-1'); + assert.strictEqual(result2, 'notebook-2'); + }); + }); + + suite('integration scenarios', () => { + test('should handle complete workflow for multiple files', () => { + const uri1 = 'file:///project1.deepnote'; + const uri2 = 'file:///project2.deepnote'; + + manager.storeOriginalProject('project-1', mockProject, 'notebook-1'); + manager.setSelectedNotebookForUri(uri1, 'notebook-1'); + + manager.storeOriginalProject('project-2', mockProject, 'notebook-2'); + manager.setSelectedNotebookForUri(uri2, 'notebook-2'); + + assert.strictEqual(manager.getCurrentNotebookId('project-1'), 'notebook-1'); + assert.strictEqual(manager.getCurrentNotebookId('project-2'), 'notebook-2'); + assert.strictEqual(manager.getSelectedNotebookForUri(uri1), 'notebook-1'); + assert.strictEqual(manager.getSelectedNotebookForUri(uri2), 'notebook-2'); + assert.strictEqual(manager.shouldSkipPrompt(uri1), true); + assert.strictEqual(manager.shouldSkipPrompt(uri2), true); + assert.strictEqual(manager.shouldSkipPrompt(uri1), false); + assert.strictEqual(manager.shouldSkipPrompt(uri2), false); + }); + + test('should handle notebook switching within same project', () => { + const uri = 'file:///project.deepnote'; + + manager.storeOriginalProject('project-123', mockProject, 'notebook-1'); + manager.setSelectedNotebookForUri(uri, 'notebook-1'); + + manager.updateCurrentNotebookId('project-123', 'notebook-2'); + manager.setSelectedNotebookForUri(uri, 'notebook-2'); + + assert.strictEqual(manager.getCurrentNotebookId('project-123'), 'notebook-2'); + assert.strictEqual(manager.getSelectedNotebookForUri(uri), 'notebook-2'); + assert.strictEqual(manager.shouldSkipPrompt(uri), true); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteNotebookSelector.ts b/src/notebooks/deepnote/deepnoteNotebookSelector.ts new file mode 100644 index 0000000000..ac06263998 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookSelector.ts @@ -0,0 +1,83 @@ +import { l10n, type QuickPickItem, window } from 'vscode'; + +import { toPromise } from '../../platform/common/utils/events'; +import type { DeepnoteNotebook } from './deepnoteTypes'; + +interface NotebookQuickPickItem extends QuickPickItem { + notebook: DeepnoteNotebook; +} + +/** + * Provides user interface for selecting notebooks within a Deepnote project. + * Creates and manages VS Code QuickPick interface for notebook selection. + */ +export class DeepnoteNotebookSelector { + /** + * Presents a notebook selection interface to the user. + * @param notebooks Available notebooks to choose from + * @param currentNotebookId Currently selected notebook ID for pre-selection + * @param options Optional configuration for the selection UI + * @returns Promise resolving to selected notebook or undefined if cancelled + */ + async selectNotebook( + notebooks: DeepnoteNotebook[], + currentNotebookId?: string, + options?: { + title?: string; + placeHolder?: string; + } + ): Promise { + const items: NotebookQuickPickItem[] = notebooks.map((notebook) => ({ + label: notebook.name, + description: this.getDescription(notebook, currentNotebookId), + detail: this.getDetail(notebook), + notebook + })); + + // Use createQuickPick for more control over selection + const quickPick = window.createQuickPick(); + quickPick.items = items; + quickPick.placeholder = options?.placeHolder || l10n.t('Select a notebook to open'); + quickPick.title = options?.title || l10n.t('Select Notebook'); + quickPick.ignoreFocusOut = false; + + // Pre-select the current notebook if provided + if (currentNotebookId) { + const activeItem = items.find((item) => item.notebook.id === currentNotebookId); + if (activeItem) { + quickPick.activeItems = [activeItem]; + } + } + + let accepted = false; + quickPick.show(); + + await Promise.race([ + toPromise(quickPick.onDidAccept).then(() => (accepted = true)), + toPromise(quickPick.onDidHide) + ]); + + const selectedItem = accepted ? quickPick.selectedItems[0] : undefined; + quickPick.dispose(); + + return selectedItem?.notebook; + } + + private getDescription(notebook: DeepnoteNotebook, currentNotebookId?: string): string { + const cellCount = notebook.blocks.length; + + if (notebook.id === currentNotebookId) { + return l10n.t('{0} cells (current)', cellCount); + } + + return l10n.t('{0} cells', cellCount); + } + + private getDetail(notebook: DeepnoteNotebook): string { + if (notebook.workingDirectory) { + return l10n.t('ID: {0} | Working Directory: {1}', notebook.id, notebook.workingDirectory); + } + + return l10n.t('ID: {0}', notebook.id); + } +} diff --git a/src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts new file mode 100644 index 0000000000..ee5f38f8af --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts @@ -0,0 +1,147 @@ +import * as assert from 'assert'; + +import { DeepnoteNotebookSelector } from './deepnoteNotebookSelector'; +import type { DeepnoteNotebook } from './deepnoteTypes'; + +suite('DeepnoteNotebookSelector', () => { + let selector: DeepnoteNotebookSelector; + + const mockNotebooks: DeepnoteNotebook[] = [ + { + blocks: [{ content: 'print("hello")', id: '1', sortingKey: '001', type: 'code' }], + executionMode: 'python', + id: 'notebook-1', + isModule: false, + name: 'My Notebook', + workingDirectory: '/home/user' + }, + { + blocks: [ + { content: '# Header', id: '2', sortingKey: '001', type: 'markdown' }, + { content: 'print("world")', id: '3', sortingKey: '002', type: 'code' } + ], + executionMode: 'python', + id: 'notebook-2', + isModule: true, + name: 'Another Notebook' + } + ]; + + setup(() => { + selector = new DeepnoteNotebookSelector(); + }); + + suite('getDescription', () => { + test('should return current notebook description for matching notebook', () => { + const description = (selector as any).getDescription(mockNotebooks[0], 'notebook-1'); + + // Now using direct strings, the mock should return the English text + assert.strictEqual(description, '1 cells (current)'); + }); + + test('should return regular notebook description for non-matching notebook', () => { + const description = (selector as any).getDescription(mockNotebooks[1], 'notebook-1'); + + assert.strictEqual(description, '2 cells'); + }); + + test('should handle notebook with no blocks', () => { + const emptyNotebook: DeepnoteNotebook = { + blocks: [], + executionMode: 'python', + id: 'empty', + isModule: false, + name: 'Empty Notebook' + }; + + const description = (selector as any).getDescription(emptyNotebook); + + assert.strictEqual(description, '0 cells'); + }); + + test('should return correct cell count', () => { + const description = (selector as any).getDescription(mockNotebooks[1]); + + assert.strictEqual(description, '2 cells'); + }); + }); + + suite('getDetail', () => { + test('should return detail with working directory', () => { + const detail = (selector as any).getDetail(mockNotebooks[0]); + + assert.strictEqual(detail, 'ID: notebook-1 | Working Directory: /home/user'); + }); + + test('should return detail without working directory', () => { + const detail = (selector as any).getDetail(mockNotebooks[1]); + + assert.strictEqual(detail, 'ID: notebook-2'); + }); + + test('should handle notebook with empty working directory', () => { + const notebook: DeepnoteNotebook = { + ...mockNotebooks[0], + workingDirectory: '' + }; + + const detail = (selector as any).getDetail(notebook); + + assert.strictEqual(detail, 'ID: notebook-1'); + }); + + test('should include notebook ID in all cases', () => { + const detail1 = (selector as any).getDetail(mockNotebooks[0]); + const detail2 = (selector as any).getDetail(mockNotebooks[1]); + + assert.strictEqual(detail1, 'ID: notebook-1 | Working Directory: /home/user'); + assert.strictEqual(detail2, 'ID: notebook-2'); + }); + }); + + suite('activeItem selection logic', () => { + test('should find and return the active item when currentNotebookId matches', () => { + const items = mockNotebooks.map((notebook) => ({ + label: notebook.name, + description: (selector as any).getDescription(notebook, 'notebook-1'), + detail: (selector as any).getDetail(notebook), + notebook + })); + + const currentId = 'notebook-1'; + const activeItem = currentId ? items.find((item) => item.notebook.id === currentId) : undefined; + + assert.ok(activeItem); + assert.strictEqual(activeItem.notebook.id, 'notebook-1'); + assert.strictEqual(activeItem.label, 'My Notebook'); + }); + + test('should return undefined when currentNotebookId does not match any notebook', () => { + const items = mockNotebooks.map((notebook) => ({ + label: notebook.name, + description: (selector as any).getDescription(notebook, 'nonexistent-id'), + detail: (selector as any).getDetail(notebook), + notebook + })); + + const currentId = 'nonexistent-id'; + const activeItem = currentId ? items.find((item) => item.notebook.id === currentId) : undefined; + + assert.strictEqual(activeItem, undefined); + }); + + test('should return undefined when currentNotebookId is not provided', () => { + const items = mockNotebooks.map((notebook) => ({ + label: notebook.name, + description: (selector as any).getDescription(notebook), + detail: (selector as any).getDetail(notebook), + notebook + })); + + const currentId = undefined; + const activeItem = currentId ? items.find((item) => item.notebook.id === currentId) : undefined; + + assert.strictEqual(activeItem, undefined); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts new file mode 100644 index 0000000000..4db6aed5d8 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -0,0 +1,203 @@ +import { type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode'; +import * as yaml from 'js-yaml'; +import type { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes'; +import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; +import { DeepnoteNotebookSelector } from './deepnoteNotebookSelector'; +import { DeepnoteDataConverter } from './deepnoteDataConverter'; + +export { DeepnoteProject, DeepnoteNotebook, DeepnoteBlock, DeepnoteOutput } from './deepnoteTypes'; + +/** + * Callback function type for handling notebook selection during deserialization. + * @param projectId Project identifier containing the notebooks + * @param notebooks Available notebooks to choose from + * @returns Promise resolving to selected notebook or undefined + */ +export type NotebookSelectionCallback = ( + projectId: string, + notebooks: DeepnoteNotebook[] +) => Promise; + +/** + * Serializer for converting between Deepnote YAML files and VS Code notebook format. + * Handles reading/writing .deepnote files and manages project state persistence. + */ +export class DeepnoteNotebookSerializer implements NotebookSerializer { + private manager = new DeepnoteNotebookManager(); + private selector = new DeepnoteNotebookSelector(); + private converter = new DeepnoteDataConverter(); + private notebookSelectionCallback?: NotebookSelectionCallback; + + /** + * Gets the notebook manager instance for accessing project state. + * @returns DeepnoteNotebookManager instance + */ + getManager(): DeepnoteNotebookManager { + return this.manager; + } + + /** + * Gets the data converter instance for cell/block conversion. + * @returns DeepnoteDataConverter instance + */ + getConverter(): DeepnoteDataConverter { + return this.converter; + } + + /** + * Sets a custom callback for handling notebook selection during deserialization. + * @param callback Function to call when notebook selection is needed + */ + setNotebookSelectionCallback(callback: NotebookSelectionCallback) { + this.notebookSelectionCallback = callback; + } + + /** + * Deserializes a Deepnote YAML file into VS Code notebook format. + * Parses YAML, selects appropriate notebook, and converts blocks to cells. + * @param content Raw file content as bytes + * @param _token Cancellation token (unused) + * @returns Promise resolving to notebook data + */ + async deserializeNotebook(content: Uint8Array, _token: CancellationToken): Promise { + try { + const contentString = Buffer.from(content).toString('utf8'); + const deepnoteProject = yaml.load(contentString) as DeepnoteProject; + + if (!deepnoteProject.project?.notebooks) { + throw new Error('Invalid Deepnote file: no notebooks found'); + } + + const selectedNotebook = this.notebookSelectionCallback + ? await this.notebookSelectionCallback(deepnoteProject.project.id, deepnoteProject.project.notebooks) + : await this.selectNotebookForOpen(deepnoteProject.project.id, deepnoteProject.project.notebooks); + + if (!selectedNotebook) { + throw new Error('No notebook selected'); + } + + const cells = this.converter.convertBlocksToCells(selectedNotebook.blocks); + + // Store the original project for later serialization + this.manager.storeOriginalProject(deepnoteProject.project.id, deepnoteProject, selectedNotebook.id); + + return { + cells, + metadata: { + deepnoteProjectId: deepnoteProject.project.id, + deepnoteProjectName: deepnoteProject.project.name, + deepnoteNotebookId: selectedNotebook.id, + deepnoteNotebookName: selectedNotebook.name, + deepnoteVersion: deepnoteProject.version + } + }; + } catch (error) { + console.error('Error deserializing Deepnote notebook:', error); + + throw new Error( + `Failed to parse Deepnote file: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + /** + * Serializes VS Code notebook data back to Deepnote YAML format. + * Converts cells to blocks, updates project data, and generates YAML. + * @param data Notebook data to serialize + * @param _token Cancellation token (unused) + * @returns Promise resolving to YAML content as bytes + */ + async serializeNotebook(data: NotebookData, _token: CancellationToken): Promise { + try { + const projectId = data.metadata?.deepnoteProjectId; + if (!projectId) { + throw new Error('Missing Deepnote project ID in notebook metadata'); + } + + const originalProject = this.manager.getOriginalProject(projectId); + if (!originalProject) { + throw new Error('Original Deepnote project not found. Cannot save changes.'); + } + + // Get the current notebook ID (may have changed due to switching) + const notebookId = data.metadata?.deepnoteNotebookId || this.manager.getCurrentNotebookId(projectId); + if (!notebookId) { + throw new Error('Cannot determine which notebook to save'); + } + + // Find the notebook to update + const notebookIndex = originalProject.project.notebooks.findIndex((nb) => nb.id === notebookId); + if (notebookIndex === -1) { + throw new Error(`Notebook with ID ${notebookId} not found in project`); + } + + // Create a deep copy of the project to modify + const updatedProject = JSON.parse(JSON.stringify(originalProject)) as DeepnoteProject; + + // Convert cells back to blocks + const updatedBlocks = this.converter.convertCellsToBlocks(data.cells); + + // Update the notebook's blocks + updatedProject.project.notebooks[notebookIndex].blocks = updatedBlocks; + + // Update modification timestamp + updatedProject.metadata.modifiedAt = new Date().toISOString(); + + // Convert to YAML + const yamlString = yaml.dump(updatedProject, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false + }); + + // Store the updated project for future saves + this.manager.storeOriginalProject(projectId, updatedProject, notebookId); + + return new TextEncoder().encode(yamlString); + } catch (error) { + console.error('Error serializing Deepnote notebook:', error); + throw new Error( + `Failed to save Deepnote file: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + private async selectNotebookForOpen( + projectId: string, + notebooks: DeepnoteNotebook[] + ): Promise { + const fileId = projectId; + const skipPrompt = this.manager.shouldSkipPrompt(fileId); + const storedNotebookId = this.manager.getSelectedNotebookForUri(fileId); + + if (notebooks.length === 1) { + return notebooks[0]; + } + + if (skipPrompt && storedNotebookId) { + // Use the stored selection when triggered by command + const preSelected = notebooks.find((nb) => nb.id === storedNotebookId); + return preSelected || notebooks[0]; + } + + if (storedNotebookId && !skipPrompt) { + // Normal file open - check if we have a previously selected notebook + const preSelected = notebooks.find((nb) => nb.id === storedNotebookId); + if (preSelected) { + return preSelected; + } + // Previously selected notebook not found, prompt for selection + } + + // Prompt user to select a notebook + const selected = await this.selector.selectNotebook(notebooks); + if (selected) { + this.manager.setSelectedNotebookForUri(fileId, selected.id); + return selected; + } + + // If user cancelled selection, default to the first notebook + return notebooks[0]; + } +} diff --git a/src/notebooks/deepnote/deepnoteTypes.ts b/src/notebooks/deepnote/deepnoteTypes.ts new file mode 100644 index 0000000000..e8388986e4 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteTypes.ts @@ -0,0 +1,62 @@ +/** + * Represents a complete Deepnote project structure with metadata, notebooks, and settings. + */ +export interface DeepnoteProject { + metadata: { + /** ISO timestamp when the project was created */ + createdAt: string; + /** ISO timestamp when the project was last modified */ + modifiedAt: string; + }; + project: { + id: string; + name: string; + notebooks: DeepnoteNotebook[]; + settings: Record; + }; + version: string; +} + +/** + * Represents a single notebook within a Deepnote project. + */ +export interface DeepnoteNotebook { + blocks: DeepnoteBlock[]; + executionMode: string; + id: string; + isModule: boolean; + name: string; + workingDirectory?: string; +} + +/** + * Represents a single block (cell) within a Deepnote notebook. + * Can be either a code block or a markdown block. + */ +export interface DeepnoteBlock { + content: string; + executionCount?: number; + id: string; + metadata?: Record; + outputReference?: string; + outputs?: DeepnoteOutput[]; + sortingKey: string; + type: 'code' | 'markdown'; +} + +/** + * Represents output data generated by executing a code block. + */ +export interface DeepnoteOutput { + data?: Record; + ename?: string; + error?: unknown; + evalue?: string; + execution_count?: number; + metadata?: Record; + name?: string; + output_type: string; + stack?: string; + text?: string; + traceback?: string[]; +} diff --git a/src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts new file mode 100644 index 0000000000..f7f86e91dc --- /dev/null +++ b/src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts @@ -0,0 +1,106 @@ +import { NotebookCellOutputItem, l10n } from 'vscode'; +import { decodeContent, parseJsonSafely } from '../dataConversionUtils'; +import type { DeepnoteOutput } from '../deepnoteTypes'; + +/** + * Handles error outputs conversion between Deepnote and VS Code formats + */ +export class ErrorOutputHandler { + /** + * Convert VS Code error output to Deepnote format + */ + convertToDeepnote(errorItem: { mime: string; data: Uint8Array }): DeepnoteOutput { + const deepnoteOutput: DeepnoteOutput = { + output_type: 'error' + }; + + try { + const errorData = parseJsonSafely(decodeContent(errorItem.data)); + if (typeof errorData === 'object' && errorData !== null) { + const errorObj = errorData as Record; + deepnoteOutput.ename = (errorObj.ename as string) || 'Error'; + deepnoteOutput.evalue = (errorObj.evalue as string) || ''; + deepnoteOutput.traceback = (errorObj.traceback as string[]) || []; + } else { + // Fallback if error data is not valid JSON object + const errorText = String(errorData); + deepnoteOutput.ename = 'Error'; + deepnoteOutput.evalue = errorText; + deepnoteOutput.traceback = [errorText]; + } + } catch { + // Final fallback if parsing completely fails + const errorText = decodeContent(errorItem.data); + deepnoteOutput.ename = 'Error'; + deepnoteOutput.evalue = errorText; + deepnoteOutput.traceback = [errorText]; + } + + return deepnoteOutput; + } + + /** + * Convert Deepnote error output to VS Code format + */ + convertToVSCode(output: DeepnoteOutput): NotebookCellOutputItem[] { + const errorMessage = this.buildErrorMessage(output); + const error = new Error(errorMessage); + + // Add structured data as error properties for debugging + if (output.ename) { + error.name = output.ename; + } + if (output.evalue) { + Object.assign(error, { evalue: output.evalue }); + } + if (output.traceback) { + Object.assign(error, { traceback: output.traceback }); + } + if (output.error) { + Object.assign(error, { deepnoteError: output.error }); + } + + return [NotebookCellOutputItem.error(error)]; + } + + /** + * Build comprehensive error message with structured data + */ + private buildErrorMessage(output: DeepnoteOutput): string { + const baseMessage = output.text || l10n.t('Error occurred during execution'); + + // Collect structured error details + const errorDetails: string[] = []; + + if (output.ename) { + errorDetails.push(l10n.t('Error Name: {0}', output.ename)); + } + + if (output.evalue) { + errorDetails.push(l10n.t('Error Value: {0}', output.evalue)); + } + + // Add any additional structured fields from metadata or direct properties + if (output.error) { + errorDetails.push(l10n.t('Error Details: {0}', JSON.stringify(output.error))); + } + + if (output.name && output.name !== output.ename) { + errorDetails.push(l10n.t('Error Type: {0}', output.name)); + } + + if (output.stack) { + errorDetails.push(l10n.t('Stack Trace: {0}', output.stack)); + } + + // Include traceback if available + if (output.traceback && Array.isArray(output.traceback) && output.traceback.length > 0) { + errorDetails.push(l10n.t('Traceback:\n{0}', output.traceback.join('\n'))); + } + + // Combine base message with structured details + return errorDetails.length > 0 + ? `${baseMessage}\n\n${l10n.t('Error Details:')}\n${errorDetails.join('\n')}` + : baseMessage; + } +} diff --git a/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts new file mode 100644 index 0000000000..705304777c --- /dev/null +++ b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts @@ -0,0 +1,73 @@ +import { NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; +import { decodeContent } from '../dataConversionUtils'; +import { MimeTypeProcessorRegistry } from '../MimeTypeProcessor'; +import { OutputTypeDetector } from '../OutputTypeDetector'; +import type { DeepnoteOutput } from '../deepnoteTypes'; + +/** + * Handles rich/display data outputs conversion between Deepnote and VS Code formats + */ +export class RichOutputHandler { + private readonly mimeRegistry = new MimeTypeProcessorRegistry(); + private readonly outputDetector = new OutputTypeDetector(); + + /** + * Convert VS Code rich output to Deepnote format + */ + convertToDeepnote(output: NotebookCellOutput): DeepnoteOutput { + const deepnoteOutput: DeepnoteOutput = { + output_type: 'execute_result', + data: {} + }; + + let hasDisplayData = false; + + for (const item of output.items) { + // Skip stream and error mimes + if (!this.outputDetector.isStreamMime(item.mime) && item.mime !== 'application/vnd.code.notebook.error') { + try { + const decodedContent = decodeContent(item.data); + deepnoteOutput.data![item.mime] = this.mimeRegistry.processForDeepnote(decodedContent, item.mime); + hasDisplayData = true; + } catch (error) { + // Fallback: treat as text if any processing fails + try { + const decodedContent = decodeContent(item.data); + deepnoteOutput.data![item.mime] = decodedContent; + hasDisplayData = true; + } catch { + // Skip this item if even text decoding fails + console.warn(`Failed to process output item with mime type: ${item.mime}`, error); + } + } + } + } + + if (hasDisplayData) { + // Use display_data for rich outputs without execution count, execute_result for those with + deepnoteOutput.output_type = deepnoteOutput.execution_count ? 'execute_result' : 'display_data'; + } + + return deepnoteOutput; + } + + /** + * Convert Deepnote rich output to VS Code format + */ + convertToVSCode(output: DeepnoteOutput): NotebookCellOutputItem[] { + if (!output.data) { + return output.text ? [NotebookCellOutputItem.text(output.text)] : []; + } + + const items: NotebookCellOutputItem[] = []; + + for (const [mimeType, content] of Object.entries(output.data)) { + const item = this.mimeRegistry.processForVSCode(content, mimeType); + if (item) { + items.push(item); + } + } + + return items; + } +} diff --git a/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts new file mode 100644 index 0000000000..9a60245c50 --- /dev/null +++ b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts @@ -0,0 +1,55 @@ +import { NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; +import { decodeContent } from '../dataConversionUtils'; +import type { DeepnoteOutput } from '../deepnoteTypes'; + +/** + * Handles stream outputs (stdout/stderr) conversion between Deepnote and VS Code formats + */ +export class StreamOutputHandler { + private readonly streamMimes = [ + 'text/plain', + 'application/vnd.code.notebook.stdout', + 'application/vnd.code.notebook.stderr' + ]; + + /** + * Convert VS Code stream output to Deepnote format + */ + convertToDeepnote(output: NotebookCellOutput): DeepnoteOutput { + const streamItems = output.items.filter((item) => this.streamMimes.includes(item.mime)); + + // Combine all stream text + const streamTexts = streamItems.map((item) => decodeContent(item.data)); + const text = streamTexts.join(''); + + const deepnoteOutput: DeepnoteOutput = { + output_type: 'stream', + text + }; + + // Only set stream name if we can determine it from mime type + const stderrItem = streamItems.find((item) => item.mime === 'application/vnd.code.notebook.stderr'); + if (stderrItem) { + deepnoteOutput.name = 'stderr'; + } + + return deepnoteOutput; + } + + /** + * Convert Deepnote stream output to VS Code format + */ + convertToVSCode(output: DeepnoteOutput): NotebookCellOutputItem[] { + if (!output.text) { + return []; + } + + // Route to appropriate stream type based on Deepnote stream name + if (output.name === 'stderr') { + return [NotebookCellOutputItem.stderr(output.text)]; + } else { + // Default to stdout for 'stdout' name or any other/missing stream name + return [NotebookCellOutputItem.stdout(output.text)]; + } + } +} diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index b5f6c8afbc..c1243342fd 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -40,6 +40,7 @@ import { CellOutputMimeTypeTracker } from './outputs/cellOutputMimeTypeTracker'; import { NotebookTracebackFormatter } from './outputs/tracebackFormatter'; import { InterpreterPackageTracker } from './telemetry/interpreterPackageTracker.node'; import { INotebookEditorProvider, INotebookPythonEnvironmentService } from './types'; +import { DeepnoteActivationService } from './deepnote/deepnoteActivationService'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -108,6 +109,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, CellOutputMimeTypeTracker ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteActivationService + ); // File export/import serviceManager.addSingleton(IFileConverter, FileConverter); diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index aef8c4c595..f5323cc88f 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -35,6 +35,7 @@ import { NotebookPythonEnvironmentService } from './notebookEnvironmentService.w import { CellOutputMimeTypeTracker } from './outputs/cellOutputMimeTypeTracker'; import { NotebookTracebackFormatter } from './outputs/tracebackFormatter'; import { INotebookEditorProvider, INotebookPythonEnvironmentService } from './types'; +import { DeepnoteActivationService } from './deepnote/deepnoteActivationService'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -84,6 +85,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, CellOutputMimeTypeTracker ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteActivationService + ); serviceManager.addSingleton(IExportBase, ExportBase); serviceManager.addSingleton(IFileConverter, FileConverter); diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 0710e5e7c3..2748c8a325 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -218,6 +218,7 @@ export namespace Commands { export const ScrollToCell = 'jupyter.scrolltocell'; export const CreateNewNotebook = 'jupyter.createnewnotebook'; export const ViewJupyterOutput = 'jupyter.viewOutput'; + export const SelectDeepnoteNotebook = 'jupyter.selectDeepnoteNotebook'; export const ExportAsPythonScript = 'jupyter.exportAsPythonScript'; export const ExportToHTML = 'jupyter.exportToHTML'; export const ExportToPDF = 'jupyter.exportToPDF'; diff --git a/src/standalone/api/kernels/kernel.unit.test.ts b/src/standalone/api/kernels/kernel.unit.test.ts index ed1a4a3311..b615fa5828 100644 --- a/src/standalone/api/kernels/kernel.unit.test.ts +++ b/src/standalone/api/kernels/kernel.unit.test.ts @@ -128,6 +128,7 @@ suite('Kernel Api', () => { when(kernel.dispose()).thenCall(() => when(kernel.status).thenReturn('dead')); const { api } = createKernelApiForExtension(JVSC_EXTENSION_ID_FOR_TESTS, instance(kernel)); + // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _ of api.executeCode('bogus', token)) { // } From 71bf5c5deb35032e7f6b1dbdd6d2fc1672c1e5c0 Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Wed, 10 Sep 2025 09:21:35 +0200 Subject: [PATCH 2/5] pr feedback --- .github/workflows/ci.yml | 11 +- .github/workflows/deps.yml | 8 +- package.json | 59 ++--- src/notebooks/deepnote/MimeTypeProcessor.ts | 12 +- src/notebooks/deepnote/OutputTypeDetector.ts | 15 +- .../deepnote/deepnoteDataConverter.ts | 30 ++- .../deepnoteDataConverter.unit.test.ts | 228 +++++++++++++++--- .../deepnote/deepnoteNotebookSelector.ts | 14 +- .../deepnoteNotebookSelector.unit.test.ts | 2 +- src/notebooks/deepnote/deepnoteSerializer.ts | 22 +- .../outputHandlers/ErrorOutputHandler.ts | 86 +++---- .../outputHandlers/RichOutputHandler.ts | 37 +-- .../outputHandlers/StreamOutputHandler.ts | 18 +- 13 files changed, 376 insertions(+), 166 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e58537f75..9bc6a1bbe3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,6 @@ on: env: NODE_VERSION: 22.x - NPM_VERSION: 10.x permissions: contents: read @@ -23,6 +22,7 @@ jobs: lint: name: Lint & Format runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 @@ -36,18 +36,16 @@ jobs: - name: Install dependencies run: npm ci --prefer-offline --no-audit - - name: Run postinstall - run: npm run postinstall - - name: Run ESLint run: npm run lint - name: Check Prettier formatting - run: npm run format + run: npx prettier --check . build: name: Build & Test runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 @@ -61,9 +59,6 @@ jobs: - name: Install dependencies run: npm ci --prefer-offline --no-audit - - name: Run postinstall - run: npm run postinstall - - name: Compile TypeScript run: npm run compile diff --git a/.github/workflows/deps.yml b/.github/workflows/deps.yml index 97b45fb8d6..2124e281ca 100644 --- a/.github/workflows/deps.yml +++ b/.github/workflows/deps.yml @@ -13,13 +13,17 @@ permissions: contents: read actions: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: audit: name: Security Audit runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 @@ -48,7 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/package.json b/package.json index 07643b13b1..c7130fdb31 100644 --- a/package.json +++ b/package.json @@ -319,7 +319,7 @@ }, { "command": "jupyter.selectDeepnoteNotebook", - "title": "Select Notebook", + "title": "%deepnote.command.selectNotebook.title%", "category": "Deepnote", "enablement": "notebookType == 'deepnote'" }, @@ -2233,48 +2233,49 @@ "notebookReplDocument" ], "scripts": { - "package": "gulp clean && npm run build && vsce package -o ms-toolsai-jupyter-insiders.vsix", - "build:stable": "cross-env IS_PRE_RELEASE_VERSION_OF_JUPYTER_EXTENSION=false npm run build", "build:prerelease": "cross-env IS_PRE_RELEASE_VERSION_OF_JUPYTER_EXTENSION=true npm run build", + "build:stable": "cross-env IS_PRE_RELEASE_VERSION_OF_JUPYTER_EXTENSION=false npm run build", "build": "concurrently npm:compile-release npm:updatePackageJsonForBundle", - "updatePackageJsonForBundle": "gulp updatePackageJsonForBundle", + "checkDependencies": "gulp checkDependencies", "clean": "gulp clean", - "compile": "concurrently npm:compile-tsc npm:esbuild-all", - "watch": "concurrently \"tsc --watch -p ./\" \"npx tsx build/esbuild/build.ts --watch\"", - "watch-all": "concurrently \"tsc --watch -p ./\" \"npx tsx build/esbuild/build.ts --watch-all\"", "compile-esbuild-watch": "npx tsx build/esbuild/build.ts --watch", + "compile-release": "concurrently npm:compile-tsc npm:esbuild-release", "compile-tsc-watch": "tsc -p . --watch", "compile-tsc": "tsc -p ./", - "compile-release": "concurrently npm:compile-tsc npm:esbuild-release", + "compile": "concurrently npm:compile-tsc npm:esbuild-all", + "download-api": "npx vscode-dts dev", "esbuild-all": "npx tsx build/esbuild/build.ts", "esbuild-release": "npx tsx build/esbuild/build.ts --production", - "checkDependencies": "gulp checkDependencies", + "format-fix": "prettier --write *.js *.json 'src/**/*.ts*' 'build/**/*.js' '.github/**/*.yml' 'build/**/*.yml' 'build/**/*.ts' 'build/**/*.json' gulpfile.js", + "format": "prettier --check *.js *.json 'src/**/*.ts*' 'build/**/*.js' '.github/**/*.yml' 'build/**/*.yml' 'build/**/*.ts' 'build/**/*.json' gulpfile.js", + "generateTelemetry": "gulp generateTelemetry", + "lint-fix": "eslint --fix --ext .ts,.js src build pythonExtensionApi gulpfile.js", + "lint": "eslint --ext .ts,.js src", + "openInBrowser": "vscode-test-web --extensionDevelopmentPath=. ./src/test/datascience", + "package": "gulp clean && npm run build && vsce package -o ms-toolsai-jupyter-insiders.vsix", + "postdownload-api": "npx vscode-dts main", "postinstall": "npm run download-api && node ./build/ci/postInstall.js", - "test": "npm run test:unittests", - "test:unittests": "mocha --config ./build/.mocha.unittests.js.json ./out/**/*.unit.test.js", - "test:cover:report": "nyc report --reporter=text --reporter=html --reporter=text-summary --reporter=cobertura", + "pretest:integration:nonpython": "cross-env VSC_JUPYTER_CI_TEST_VSC_CHANNEL=insiders node ./out/test/datascience/dsTestSetup.js", "pretest:integration": "node ./out/test/datascience/dsTestSetup.js", - "test:integration": "cross-env CODE_TESTS_WORKSPACE=src/test/datascience TEST_FILES_SUFFIX=*.vscode.test,*.vscode.common.test node ./out/test/standardTest.node.js", - "pretest:performance:notebook": "cross-env VSC_JUPYTER_CI_TEST_GREP=@notebookPerformance VSC_JUPYTER_CI_TEST_DO_NOT_INSTALL_PYTHON_EXT=true node ./out/test/datascience/dsTestSetup.js", - "test:performance:notebook": "cross-env VSC_JUPYTER_CI_TEST_GREP=@notebookPerformance VSC_JUPYTER_CI_TEST_DO_NOT_INSTALL_PYTHON_EXT=true CODE_TESTS_WORKSPACE=src/test/datascience TEST_FILES_SUFFIX=*.vscode.test,*.vscode.common.test node ./out/test/standardTest.node.js", "pretest:performance:execution": "cross-env VSC_JUPYTER_CI_TEST_GREP=@executionPerformance node ./out/test/datascience/dsTestSetup.js", - "test:performance:execution": "cross-env VSC_JUPYTER_CI_TEST_GREP=@executionPerformance CODE_TESTS_WORKSPACE=src/test/datascience TEST_FILES_SUFFIX=*.vscode.test,*.vscode.common.test VSC_JUPYTER_FORCE_LOGGING= node ./out/test/standardTest.node.js", - "test:integration:windows": "cross-env CODE_TESTS_WORKSPACE=src/test/datascience node ./out/test/standardTest.node.js", - "pretest:integration:nonpython": "cross-env VSC_JUPYTER_CI_TEST_VSC_CHANNEL=insiders node ./out/test/datascience/dsTestSetup.js", + "pretest:performance:notebook": "cross-env VSC_JUPYTER_CI_TEST_GREP=@notebookPerformance VSC_JUPYTER_CI_TEST_DO_NOT_INSTALL_PYTHON_EXT=true node ./out/test/datascience/dsTestSetup.js", + "setup-precommit-hook": "husky install", + "startJupyterServer": "node build/preDebugWebTest.js", + "stopJupyterServer": "node build/postDebugWebTest.js", + "test:cover:report": "nyc report --reporter=text --reporter=html --reporter=text-summary --reporter=cobertura", "test:integration:nonpython": "cross-env CODE_TESTS_WORKSPACE=src/test/datascience TEST_FILES_SUFFIX=*.vscode.test,*.vscode.common.test VSC_JUPYTER_CI_TEST_GREP=@nonPython VSC_JUPYTER_CI_TEST_DO_NOT_INSTALL_PYTHON_EXT=true node ./out/test/standardTest.node.js", "test:integration:web": "node ./build/launchWebTest.js", + "test:integration:windows": "cross-env CODE_TESTS_WORKSPACE=src/test/datascience node ./out/test/standardTest.node.js", + "test:integration": "cross-env CODE_TESTS_WORKSPACE=src/test/datascience TEST_FILES_SUFFIX=*.vscode.test,*.vscode.common.test node ./out/test/standardTest.node.js", + "test:performance:execution": "cross-env VSC_JUPYTER_CI_TEST_GREP=@executionPerformance CODE_TESTS_WORKSPACE=src/test/datascience TEST_FILES_SUFFIX=*.vscode.test,*.vscode.common.test VSC_JUPYTER_FORCE_LOGGING= node ./out/test/standardTest.node.js", + "test:performance:notebook": "cross-env VSC_JUPYTER_CI_TEST_GREP=@notebookPerformance VSC_JUPYTER_CI_TEST_DO_NOT_INSTALL_PYTHON_EXT=true CODE_TESTS_WORKSPACE=src/test/datascience TEST_FILES_SUFFIX=*.vscode.test,*.vscode.common.test node ./out/test/standardTest.node.js", "test:smoke": "cross-env VSC_JUPYTER_FORCE_LOGGING=true node --no-force-async-hooks-checks ./out/test/smokeTest.node.js", - "lint": "eslint --ext .ts,.js src", - "lint-fix": "eslint --fix --ext .ts,.js src build pythonExtensionApi gulpfile.js", - "format": "prettier --check *.js *.json 'src/**/*.ts*' 'build/**/*.js' '.github/**/*.yml' 'build/**/*.yml' 'build/**/*.ts' 'build/**/*.json' gulpfile.js", - "format-fix": "prettier --write *.js *.json 'src/**/*.ts*' 'build/**/*.js' '.github/**/*.yml' 'build/**/*.yml' 'build/**/*.ts' 'build/**/*.json' gulpfile.js", - "download-api": "npx vscode-dts dev", - "postdownload-api": "npx vscode-dts main", - "generateTelemetry": "gulp generateTelemetry", - "openInBrowser": "vscode-test-web --extensionDevelopmentPath=. ./src/test/datascience", - "startJupyterServer": "node build/preDebugWebTest.js", - "stopJupyterServer": "node build/postDebugWebTest.js", - "setup-precommit-hook": "husky install" + "test:unittests": "mocha --config ./build/.mocha.unittests.js.json ./out/**/*.unit.test.js", + "test": "npm run test:unittests", + "typecheck": "tsc -p ./ --noEmit", + "updatePackageJsonForBundle": "gulp updatePackageJsonForBundle", + "watch-all": "concurrently \"tsc --watch -p ./\" \"npx tsx build/esbuild/build.ts --watch-all\"", + "watch": "concurrently \"tsc --watch -p ./\" \"npx tsx build/esbuild/build.ts --watch\"" }, "dependencies": { "@c4312/evt": "^0.1.1", diff --git a/src/notebooks/deepnote/MimeTypeProcessor.ts b/src/notebooks/deepnote/MimeTypeProcessor.ts index e5bc915a45..eddaf24939 100644 --- a/src/notebooks/deepnote/MimeTypeProcessor.ts +++ b/src/notebooks/deepnote/MimeTypeProcessor.ts @@ -23,7 +23,7 @@ export class TextMimeProcessor implements MimeProcessor { processForVSCode(content: unknown, mimeType: string): NotebookCellOutputItem | null { if (mimeType === 'text/plain') { - return NotebookCellOutputItem.text(content as string); + return NotebookCellOutputItem.text(content as string, 'text/plain'); } if (mimeType === 'text/html') { return NotebookCellOutputItem.text(content as string, 'text/html'); @@ -40,11 +40,12 @@ export class ImageMimeProcessor implements MimeProcessor { return mimeType.startsWith('image/'); } - processForDeepnote(content: unknown, mimeType: string): unknown { + processForDeepnote(content: unknown, _mimeType: string): unknown { if (content instanceof Uint8Array) { const base64String = btoa(String.fromCharCode(...content)); - return `data:${mimeType};base64,${base64String}`; + return base64String; } + // If it's already a string (base64 or data URL), return as-is return content; } @@ -54,6 +55,11 @@ export class ImageMimeProcessor implements MimeProcessor { if (typeof content === 'string') { uint8Array = convertBase64ToUint8Array(content); + // Store the original base64 string for round-trip preservation + const item = new NotebookCellOutputItem(uint8Array, mimeType); + // Use a property that won't interfere with VS Code but preserves the original data + (item as any)._originalBase64 = content; + return item; } else if (content instanceof ArrayBuffer) { uint8Array = new Uint8Array(content); } else if (content instanceof Uint8Array) { diff --git a/src/notebooks/deepnote/OutputTypeDetector.ts b/src/notebooks/deepnote/OutputTypeDetector.ts index 94d36515fb..d69f8a9dc8 100644 --- a/src/notebooks/deepnote/OutputTypeDetector.ts +++ b/src/notebooks/deepnote/OutputTypeDetector.ts @@ -32,9 +32,18 @@ export class OutputTypeDetector { }; } - // Check for stream outputs - const streamItems = output.items.filter((item) => this.streamMimes.includes(item.mime)); - if (streamItems.length > 0) { + // Check for stream outputs - only if ALL items are stream mimes + // or if it contains stdout/stderr specific mimes + const hasStdoutStderr = output.items.some( + (item) => + item.mime === 'application/vnd.code.notebook.stdout' || + item.mime === 'application/vnd.code.notebook.stderr' + ); + + const allItemsAreStream = output.items.every((item) => this.streamMimes.includes(item.mime)); + + if (hasStdoutStderr || (allItemsAreStream && output.items.length === 1)) { + const streamItems = output.items.filter((item) => this.streamMimes.includes(item.mime)); return { type: 'stream', streamMimes: streamItems.map((item) => item.mime) diff --git a/src/notebooks/deepnote/deepnoteDataConverter.ts b/src/notebooks/deepnote/deepnoteDataConverter.ts index 59f640dfff..d24a127ce7 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.ts @@ -24,9 +24,7 @@ export class DeepnoteDataConverter { * @returns Array of VS Code notebook cell data */ convertBlocksToCells(blocks: DeepnoteBlock[]): NotebookCellData[] { - return blocks - .sort((a, b) => a.sortingKey.localeCompare(b.sortingKey)) - .map((block) => this.convertBlockToCell(block)); + return blocks.map((block) => this.convertBlockToCell(block)); } /** @@ -62,18 +60,23 @@ export class DeepnoteDataConverter { private convertCellToBlock(cell: NotebookCellData, index: number): DeepnoteBlock { const blockId = cell.metadata?.deepnoteBlockId || generateBlockId(); const sortingKey = cell.metadata?.deepnoteSortingKey || generateSortingKey(index); - const originalMetadata = cell.metadata?.deepnoteMetadata || {}; + const originalMetadata = cell.metadata?.deepnoteMetadata; const block: DeepnoteBlock = { id: blockId, sortingKey: sortingKey, type: cell.kind === NotebookCellKind.Code ? 'code' : 'markdown', - content: cell.value, - metadata: originalMetadata + content: cell.value }; + // Only add metadata if it exists and is not empty + if (originalMetadata && Object.keys(originalMetadata).length > 0) { + block.metadata = originalMetadata; + } + if (cell.kind === NotebookCellKind.Code) { - const executionCount = cell.metadata?.executionCount || cell.executionSummary?.executionOrder; + const executionCount = cell.metadata?.executionCount ?? cell.executionSummary?.executionOrder; + if (executionCount !== undefined) { block.executionCount = executionCount; } @@ -131,11 +134,16 @@ export class DeepnoteDataConverter { // Preserve metadata from VS Code output if (output.metadata) { - deepnoteOutput.metadata = mergeMetadata(deepnoteOutput.metadata, output.metadata); + // Extract execution count from metadata before merging + const { executionCount, ...restMetadata } = output.metadata; + + if (executionCount !== undefined && deepnoteOutput.execution_count === undefined) { + deepnoteOutput.execution_count = executionCount as number; + } - // Extract execution count from metadata - if (output.metadata.executionCount !== undefined) { - deepnoteOutput.execution_count = output.metadata.executionCount as number; + // Only merge non-executionCount metadata + if (Object.keys(restMetadata).length > 0) { + deepnoteOutput.metadata = mergeMetadata(deepnoteOutput.metadata, restMetadata); } } diff --git a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts index 7d6d778135..a586497db2 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts @@ -55,36 +55,6 @@ suite('DeepnoteDataConverter', () => { assert.strictEqual(cells[0].metadata?.deepnoteBlockType, 'markdown'); }); - test('sorts blocks by sortingKey', () => { - const blocks: DeepnoteBlock[] = [ - { - id: 'block2', - type: 'code', - content: 'second', - sortingKey: 'b0' - }, - { - id: 'block1', - type: 'code', - content: 'first', - sortingKey: 'a0' - }, - { - id: 'block3', - type: 'code', - content: 'third', - sortingKey: 'c0' - } - ]; - - const cells = converter.convertBlocksToCells(blocks); - - assert.strictEqual(cells.length, 3); - assert.strictEqual(cells[0].value, 'first'); - assert.strictEqual(cells[1].value, 'second'); - assert.strictEqual(cells[2].value, 'third'); - }); - test('handles execution count and output reference', () => { const blocks: DeepnoteBlock[] = [ { @@ -454,5 +424,203 @@ suite('DeepnoteDataConverter', () => { assert.deepStrictEqual(roundTripBlocks, originalBlocks); }); + + test('blocks -> cells -> blocks preserves minimal multi-mime outputs', () => { + const originalBlocks: DeepnoteBlock[] = [ + { + id: 'simple-multi', + type: 'code', + content: 'test', + sortingKey: 'z0', + outputs: [ + { + output_type: 'execute_result', + execution_count: 1, + data: { + 'text/plain': 'Result object', + 'text/html': '
Result
' + } + } + ] + } + ]; + + const cells = converter.convertBlocksToCells(originalBlocks); + const roundTripBlocks = converter.convertCellsToBlocks(cells); + + assert.deepStrictEqual(roundTripBlocks, originalBlocks); + }); + + test('blocks -> cells -> blocks preserves simpler multi-mime outputs', () => { + const originalBlocks: DeepnoteBlock[] = [ + { + id: 'multi-output-1', + type: 'code', + content: 'display(data)', + sortingKey: 'b0', + executionCount: 2, + outputs: [ + // Stream with name + { + output_type: 'stream', + name: 'stdout', + text: 'Starting...\n' + }, + // Execute result with multiple mimes + { + output_type: 'execute_result', + execution_count: 2, + data: { + 'text/plain': 'Result object', + 'text/html': '
Result
' + }, + metadata: { + custom: 'value' + } + }, + // Display data + { + output_type: 'display_data', + data: { + 'text/plain': 'Display text', + 'application/json': { result: true } + } + } + ] + }, + { + id: 'error-block', + type: 'code', + content: 'error()', + sortingKey: 'b1', + executionCount: 3, + outputs: [ + { + output_type: 'error', + ename: 'RuntimeError', + evalue: 'Something went wrong', + traceback: ['Line 1: error'] + } + ] + } + ]; + + const cells = converter.convertBlocksToCells(originalBlocks); + const roundTripBlocks = converter.convertCellsToBlocks(cells); + + assert.deepStrictEqual(roundTripBlocks, originalBlocks); + }); + + test('blocks -> cells -> blocks preserves complex multi-mime rich outputs', () => { + const originalBlocks: DeepnoteBlock[] = [ + { + id: 'rich-block-1', + type: 'code', + content: 'import matplotlib.pyplot as plt\nplt.plot([1,2,3])', + sortingKey: 'aa0', + executionCount: 3, + metadata: { slideshow: { slide_type: 'slide' } }, + outputs: [ + // Stream output + { + output_type: 'stream', + name: 'stdout', + text: 'Processing data...\n' + }, + // Execute result with multiple mime types + { + output_type: 'execute_result', + execution_count: 3, + data: { + 'text/plain': '', + 'text/html': '
Plot rendered
', + 'application/json': { type: 'plot', data: [1, 2, 3] } + }, + metadata: { + needs_background: 'light' + } + }, + // Display data with image + { + output_type: 'display_data', + data: { + 'image/png': + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'text/plain': '
' + }, + metadata: { + image: { + width: 640, + height: 480 + } + } + } + ] + }, + { + id: 'rich-block-2', + type: 'code', + content: 'raise ValueError("Test error")', + sortingKey: 'aa1', + executionCount: 4, + outputs: [ + // Error output with traceback + { + output_type: 'error', + ename: 'ValueError', + evalue: 'Test error', + traceback: [ + '\u001b[0;31m---------------------------------------------------------------------------\u001b[0m', + '\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)', + '\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m"Test error"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m', + '\u001b[0;31mValueError\u001b[0m: Test error' + ] + } + ] + }, + { + id: 'rich-block-3', + type: 'code', + content: + 'from IPython.display import display, HTML, JSON\ndisplay(HTML("Bold text"), JSON({"key": "value"}))', + sortingKey: 'aa2', + executionCount: 5, + outputReference: 'output-ref-789', + outputs: [ + // Multiple display_data outputs + { + output_type: 'display_data', + data: { + 'text/html': 'Bold text', + 'text/plain': 'Bold text' + } + }, + { + output_type: 'display_data', + data: { + 'application/json': { key: 'value' }, + 'text/plain': "{'key': 'value'}" + }, + metadata: { + expanded: false, + root: 'object' + } + } + ] + }, + { + id: 'markdown-block', + type: 'markdown', + content: '## Results\n\nThe above cells demonstrate various output types.', + sortingKey: 'aa3', + metadata: { tags: ['documentation'] } + } + ]; + + const cells = converter.convertBlocksToCells(originalBlocks); + const roundTripBlocks = converter.convertCellsToBlocks(cells); + + assert.deepStrictEqual(roundTripBlocks, originalBlocks); + }); }); }); diff --git a/src/notebooks/deepnote/deepnoteNotebookSelector.ts b/src/notebooks/deepnote/deepnoteNotebookSelector.ts index ac06263998..ed3b8f8887 100644 --- a/src/notebooks/deepnote/deepnoteNotebookSelector.ts +++ b/src/notebooks/deepnote/deepnoteNotebookSelector.ts @@ -40,6 +40,8 @@ export class DeepnoteNotebookSelector { quickPick.placeholder = options?.placeHolder || l10n.t('Select a notebook to open'); quickPick.title = options?.title || l10n.t('Select Notebook'); quickPick.ignoreFocusOut = false; + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; // Pre-select the current notebook if provided if (currentNotebookId) { @@ -53,11 +55,14 @@ export class DeepnoteNotebookSelector { quickPick.show(); await Promise.race([ - toPromise(quickPick.onDidAccept).then(() => (accepted = true)), + toPromise(quickPick.onDidAccept).then(() => { + accepted = true; + }), toPromise(quickPick.onDidHide) ]); const selectedItem = accepted ? quickPick.selectedItems[0] : undefined; + quickPick.dispose(); return selectedItem?.notebook; @@ -65,12 +70,9 @@ export class DeepnoteNotebookSelector { private getDescription(notebook: DeepnoteNotebook, currentNotebookId?: string): string { const cellCount = notebook.blocks.length; + const base = cellCount === 1 ? l10n.t('{0} cell', cellCount) : l10n.t('{0} cells', cellCount); - if (notebook.id === currentNotebookId) { - return l10n.t('{0} cells (current)', cellCount); - } - - return l10n.t('{0} cells', cellCount); + return notebook.id === currentNotebookId ? l10n.t('{0} (current)', base) : base; } private getDetail(notebook: DeepnoteNotebook): string { diff --git a/src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts index ee5f38f8af..ae1ee59e71 100644 --- a/src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookSelector.unit.test.ts @@ -36,7 +36,7 @@ suite('DeepnoteNotebookSelector', () => { const description = (selector as any).getDescription(mockNotebooks[0], 'notebook-1'); // Now using direct strings, the mock should return the English text - assert.strictEqual(description, '1 cells (current)'); + assert.strictEqual(description, '1 cell (current)'); }); test('should return regular notebook description for non-matching notebook', () => { diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts index 4db6aed5d8..f4e3e71ea2 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -1,4 +1,4 @@ -import { type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode'; +import { l10n, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode'; import * as yaml from 'js-yaml'; import type { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; @@ -56,12 +56,16 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { * Deserializes a Deepnote YAML file into VS Code notebook format. * Parses YAML, selects appropriate notebook, and converts blocks to cells. * @param content Raw file content as bytes - * @param _token Cancellation token (unused) + * @param token Cancellation token (unused) * @returns Promise resolving to notebook data */ - async deserializeNotebook(content: Uint8Array, _token: CancellationToken): Promise { + async deserializeNotebook(content: Uint8Array, token: CancellationToken): Promise { + if (token?.isCancellationRequested) { + throw new Error('Serialization cancelled'); + } + try { - const contentString = Buffer.from(content).toString('utf8'); + const contentString = new TextDecoder('utf-8').decode(content); const deepnoteProject = yaml.load(contentString) as DeepnoteProject; if (!deepnoteProject.project?.notebooks) { @@ -73,7 +77,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { : await this.selectNotebookForOpen(deepnoteProject.project.id, deepnoteProject.project.notebooks); if (!selectedNotebook) { - throw new Error('No notebook selected'); + throw new Error(l10n.t('No notebook selected')); } const cells = this.converter.convertBlocksToCells(selectedNotebook.blocks); @@ -104,10 +108,14 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { * Serializes VS Code notebook data back to Deepnote YAML format. * Converts cells to blocks, updates project data, and generates YAML. * @param data Notebook data to serialize - * @param _token Cancellation token (unused) + * @param token Cancellation token (unused) * @returns Promise resolving to YAML content as bytes */ - async serializeNotebook(data: NotebookData, _token: CancellationToken): Promise { + async serializeNotebook(data: NotebookData, token: CancellationToken): Promise { + if (token?.isCancellationRequested) { + throw new Error('Serialization cancelled'); + } + try { const projectId = data.metadata?.deepnoteProjectId; if (!projectId) { diff --git a/src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts index f7f86e91dc..dcc582b873 100644 --- a/src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts +++ b/src/notebooks/deepnote/outputHandlers/ErrorOutputHandler.ts @@ -1,4 +1,4 @@ -import { NotebookCellOutputItem, l10n } from 'vscode'; +import { NotebookCellOutputItem } from 'vscode'; import { decodeContent, parseJsonSafely } from '../dataConversionUtils'; import type { DeepnoteOutput } from '../deepnoteTypes'; @@ -18,9 +18,32 @@ export class ErrorOutputHandler { const errorData = parseJsonSafely(decodeContent(errorItem.data)); if (typeof errorData === 'object' && errorData !== null) { const errorObj = errorData as Record; - deepnoteOutput.ename = (errorObj.ename as string) || 'Error'; - deepnoteOutput.evalue = (errorObj.evalue as string) || ''; - deepnoteOutput.traceback = (errorObj.traceback as string[]) || []; + // Prefer Jupyter-style fields if they exist (for round-trip preservation) + // Otherwise fallback to VS Code error structure + deepnoteOutput.ename = (errorObj.ename as string) || (errorObj.name as string) || 'Error'; + deepnoteOutput.evalue = (errorObj.evalue as string) || (errorObj.message as string) || ''; + + // Handle traceback - prefer original traceback array if it exists + if (errorObj.traceback && Array.isArray(errorObj.traceback)) { + deepnoteOutput.traceback = errorObj.traceback as string[]; + } else if (errorObj.stack && typeof errorObj.stack === 'string') { + // Try extracting traceback from stack trace if custom properties weren't preserved + if (errorObj.stack.includes('__TRACEBACK_START__')) { + // Parse our special format + const tracebackMatch = errorObj.stack.match(/__TRACEBACK_START__\n(.*?)\n__TRACEBACK_END__/s); + if (tracebackMatch) { + deepnoteOutput.traceback = tracebackMatch[1].split('\n__TRACEBACK_LINE__\n'); + } else { + deepnoteOutput.traceback = []; + } + } else { + const stackLines = errorObj.stack.split('\n'); + // Skip the first line which is the error name/message + deepnoteOutput.traceback = stackLines.slice(1); + } + } else { + deepnoteOutput.traceback = []; + } } else { // Fallback if error data is not valid JSON object const errorText = String(errorData); @@ -43,18 +66,26 @@ export class ErrorOutputHandler { * Convert Deepnote error output to VS Code format */ convertToVSCode(output: DeepnoteOutput): NotebookCellOutputItem[] { - const errorMessage = this.buildErrorMessage(output); - const error = new Error(errorMessage); + // Create a simple error with just the evalue as message + const error = new Error(output.evalue || output.text || 'Error'); - // Add structured data as error properties for debugging + // Store the original Deepnote error data for round-trip preservation if (output.ename) { error.name = output.ename; + Object.assign(error, { ename: output.ename }); } if (output.evalue) { Object.assign(error, { evalue: output.evalue }); } if (output.traceback) { Object.assign(error, { traceback: output.traceback }); + // Also encode in the stack trace for better preservation + // Join traceback with a special separator that we can split on later + if (Array.isArray(output.traceback) && output.traceback.length > 0) { + error.stack = `${output.ename || 'Error'}: ${ + output.evalue || 'Unknown error' + }\n__TRACEBACK_START__\n${output.traceback.join('\n__TRACEBACK_LINE__\n')}\n__TRACEBACK_END__`; + } } if (output.error) { Object.assign(error, { deepnoteError: output.error }); @@ -62,45 +93,4 @@ export class ErrorOutputHandler { return [NotebookCellOutputItem.error(error)]; } - - /** - * Build comprehensive error message with structured data - */ - private buildErrorMessage(output: DeepnoteOutput): string { - const baseMessage = output.text || l10n.t('Error occurred during execution'); - - // Collect structured error details - const errorDetails: string[] = []; - - if (output.ename) { - errorDetails.push(l10n.t('Error Name: {0}', output.ename)); - } - - if (output.evalue) { - errorDetails.push(l10n.t('Error Value: {0}', output.evalue)); - } - - // Add any additional structured fields from metadata or direct properties - if (output.error) { - errorDetails.push(l10n.t('Error Details: {0}', JSON.stringify(output.error))); - } - - if (output.name && output.name !== output.ename) { - errorDetails.push(l10n.t('Error Type: {0}', output.name)); - } - - if (output.stack) { - errorDetails.push(l10n.t('Stack Trace: {0}', output.stack)); - } - - // Include traceback if available - if (output.traceback && Array.isArray(output.traceback) && output.traceback.length > 0) { - errorDetails.push(l10n.t('Traceback:\n{0}', output.traceback.join('\n'))); - } - - // Combine base message with structured details - return errorDetails.length > 0 - ? `${baseMessage}\n\n${l10n.t('Error Details:')}\n${errorDetails.join('\n')}` - : baseMessage; - } } diff --git a/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts index 705304777c..d469f7d4e8 100644 --- a/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts +++ b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts @@ -1,7 +1,6 @@ import { NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; import { decodeContent } from '../dataConversionUtils'; import { MimeTypeProcessorRegistry } from '../MimeTypeProcessor'; -import { OutputTypeDetector } from '../OutputTypeDetector'; import type { DeepnoteOutput } from '../deepnoteTypes'; /** @@ -9,32 +8,45 @@ import type { DeepnoteOutput } from '../deepnoteTypes'; */ export class RichOutputHandler { private readonly mimeRegistry = new MimeTypeProcessorRegistry(); - private readonly outputDetector = new OutputTypeDetector(); /** * Convert VS Code rich output to Deepnote format */ convertToDeepnote(output: NotebookCellOutput): DeepnoteOutput { const deepnoteOutput: DeepnoteOutput = { - output_type: 'execute_result', + output_type: 'display_data', data: {} }; - let hasDisplayData = false; + // Check for execution count in metadata + if (output.metadata?.executionCount !== undefined) { + deepnoteOutput.execution_count = output.metadata.executionCount; + deepnoteOutput.output_type = 'execute_result'; + } for (const item of output.items) { - // Skip stream and error mimes - if (!this.outputDetector.isStreamMime(item.mime) && item.mime !== 'application/vnd.code.notebook.error') { + // Skip only specific VS Code notebook stream and error mimes, but allow text/plain in rich context + if ( + item.mime !== 'application/vnd.code.notebook.error' && + item.mime !== 'application/vnd.code.notebook.stdout' && + item.mime !== 'application/vnd.code.notebook.stderr' + ) { try { - const decodedContent = decodeContent(item.data); - deepnoteOutput.data![item.mime] = this.mimeRegistry.processForDeepnote(decodedContent, item.mime); - hasDisplayData = true; + // Check if this item has preserved original base64 data + if ((item as any)._originalBase64 && item.mime.startsWith('image/')) { + deepnoteOutput.data![item.mime] = (item as any)._originalBase64; + } else { + const decodedContent = decodeContent(item.data); + deepnoteOutput.data![item.mime] = this.mimeRegistry.processForDeepnote( + decodedContent, + item.mime + ); + } } catch (error) { // Fallback: treat as text if any processing fails try { const decodedContent = decodeContent(item.data); deepnoteOutput.data![item.mime] = decodedContent; - hasDisplayData = true; } catch { // Skip this item if even text decoding fails console.warn(`Failed to process output item with mime type: ${item.mime}`, error); @@ -43,11 +55,6 @@ export class RichOutputHandler { } } - if (hasDisplayData) { - // Use display_data for rich outputs without execution count, execute_result for those with - deepnoteOutput.output_type = deepnoteOutput.execution_count ? 'execute_result' : 'display_data'; - } - return deepnoteOutput; } diff --git a/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts index 9a60245c50..c0c0e1dc57 100644 --- a/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts +++ b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts @@ -27,11 +27,18 @@ export class StreamOutputHandler { text }; - // Only set stream name if we can determine it from mime type + // Only set stream name if we can definitively determine it from mime type const stderrItem = streamItems.find((item) => item.mime === 'application/vnd.code.notebook.stderr'); + const stdoutItem = streamItems.find((item) => item.mime === 'application/vnd.code.notebook.stdout'); + const unnamedStreamItem = streamItems.find((item) => (item as any)._wasUnnamedStream); + if (stderrItem) { deepnoteOutput.name = 'stderr'; + } else if (stdoutItem && !unnamedStreamItem) { + // Only set stdout name if it wasn't originally unnamed + deepnoteOutput.name = 'stdout'; } + // Don't set name for streams that were originally unnamed return deepnoteOutput; } @@ -47,9 +54,14 @@ export class StreamOutputHandler { // Route to appropriate stream type based on Deepnote stream name if (output.name === 'stderr') { return [NotebookCellOutputItem.stderr(output.text)]; - } else { - // Default to stdout for 'stdout' name or any other/missing stream name + } else if (output.name === 'stdout') { return [NotebookCellOutputItem.stdout(output.text)]; + } else { + // For streams without explicit name, use stdout for proper VS Code display + // but mark it as originally unnamed for round-trip preservation + const item = NotebookCellOutputItem.stdout(output.text); + (item as any)._wasUnnamedStream = true; + return [item]; } } } From 1c57accc98d4b9ab1aeb5fa8e4e1cf5a2d141e36 Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Wed, 10 Sep 2025 09:24:42 +0200 Subject: [PATCH 3/5] lint --- src/notebooks/deepnote/MimeTypeProcessor.ts | 2 +- src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts | 4 ++-- src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/notebooks/deepnote/MimeTypeProcessor.ts b/src/notebooks/deepnote/MimeTypeProcessor.ts index eddaf24939..dff2330e56 100644 --- a/src/notebooks/deepnote/MimeTypeProcessor.ts +++ b/src/notebooks/deepnote/MimeTypeProcessor.ts @@ -58,7 +58,7 @@ export class ImageMimeProcessor implements MimeProcessor { // Store the original base64 string for round-trip preservation const item = new NotebookCellOutputItem(uint8Array, mimeType); // Use a property that won't interfere with VS Code but preserves the original data - (item as any)._originalBase64 = content; + (item as NotebookCellOutputItem & { _originalBase64?: string })._originalBase64 = content; return item; } else if (content instanceof ArrayBuffer) { uint8Array = new Uint8Array(content); diff --git a/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts index d469f7d4e8..2f84cec77d 100644 --- a/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts +++ b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts @@ -33,8 +33,8 @@ export class RichOutputHandler { ) { try { // Check if this item has preserved original base64 data - if ((item as any)._originalBase64 && item.mime.startsWith('image/')) { - deepnoteOutput.data![item.mime] = (item as any)._originalBase64; + if ((item as NotebookCellOutputItem & { _originalBase64?: string })._originalBase64 && item.mime.startsWith('image/')) { + deepnoteOutput.data![item.mime] = (item as NotebookCellOutputItem & { _originalBase64?: string })._originalBase64; } else { const decodedContent = decodeContent(item.data); deepnoteOutput.data![item.mime] = this.mimeRegistry.processForDeepnote( diff --git a/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts index c0c0e1dc57..24a52e94eb 100644 --- a/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts +++ b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts @@ -30,7 +30,7 @@ export class StreamOutputHandler { // Only set stream name if we can definitively determine it from mime type const stderrItem = streamItems.find((item) => item.mime === 'application/vnd.code.notebook.stderr'); const stdoutItem = streamItems.find((item) => item.mime === 'application/vnd.code.notebook.stdout'); - const unnamedStreamItem = streamItems.find((item) => (item as any)._wasUnnamedStream); + const unnamedStreamItem = streamItems.find((item) => (item as NotebookCellOutputItem & { _wasUnnamedStream?: boolean })._wasUnnamedStream); if (stderrItem) { deepnoteOutput.name = 'stderr'; @@ -60,7 +60,7 @@ export class StreamOutputHandler { // For streams without explicit name, use stdout for proper VS Code display // but mark it as originally unnamed for round-trip preservation const item = NotebookCellOutputItem.stdout(output.text); - (item as any)._wasUnnamedStream = true; + (item as NotebookCellOutputItem & { _wasUnnamedStream?: boolean })._wasUnnamedStream = true; return [item]; } } From 5acda40124ef537055553dfc3ff984a813d0f2f3 Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Wed, 10 Sep 2025 09:27:43 +0200 Subject: [PATCH 4/5] format --- .../deepnote/outputHandlers/RichOutputHandler.ts | 9 +++++++-- .../deepnote/outputHandlers/StreamOutputHandler.ts | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts index 2f84cec77d..f0a99e8e65 100644 --- a/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts +++ b/src/notebooks/deepnote/outputHandlers/RichOutputHandler.ts @@ -33,8 +33,13 @@ export class RichOutputHandler { ) { try { // Check if this item has preserved original base64 data - if ((item as NotebookCellOutputItem & { _originalBase64?: string })._originalBase64 && item.mime.startsWith('image/')) { - deepnoteOutput.data![item.mime] = (item as NotebookCellOutputItem & { _originalBase64?: string })._originalBase64; + if ( + (item as NotebookCellOutputItem & { _originalBase64?: string })._originalBase64 && + item.mime.startsWith('image/') + ) { + deepnoteOutput.data![item.mime] = ( + item as NotebookCellOutputItem & { _originalBase64?: string } + )._originalBase64; } else { const decodedContent = decodeContent(item.data); deepnoteOutput.data![item.mime] = this.mimeRegistry.processForDeepnote( diff --git a/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts index 24a52e94eb..3a63865284 100644 --- a/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts +++ b/src/notebooks/deepnote/outputHandlers/StreamOutputHandler.ts @@ -30,7 +30,9 @@ export class StreamOutputHandler { // Only set stream name if we can definitively determine it from mime type const stderrItem = streamItems.find((item) => item.mime === 'application/vnd.code.notebook.stderr'); const stdoutItem = streamItems.find((item) => item.mime === 'application/vnd.code.notebook.stdout'); - const unnamedStreamItem = streamItems.find((item) => (item as NotebookCellOutputItem & { _wasUnnamedStream?: boolean })._wasUnnamedStream); + const unnamedStreamItem = streamItems.find( + (item) => (item as NotebookCellOutputItem & { _wasUnnamedStream?: boolean })._wasUnnamedStream + ); if (stderrItem) { deepnoteOutput.name = 'stderr'; From 0c23d2a784ceea82d7e31baf12b0dff667bbcba1 Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Wed, 10 Sep 2025 09:30:18 +0200 Subject: [PATCH 5/5] fix lint --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bc6a1bbe3..0f547676d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: run: npm run lint - name: Check Prettier formatting - run: npx prettier --check . + run: npm run format build: name: Build & Test