From 5523994fb1b0ad2ecf35b04c6b3598aa74ad5545 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Fri, 26 Jun 2026 17:39:45 +0000 Subject: [PATCH 001/101] python notebooks in QDK Learning --- .gitignore | 4 +- .../01-intro/_exercises.json | 16 + .../circuit-diagrams-new/01-intro/_unit.py | 19 + .../circuit-diagrams-new/01-intro/intro.ipynb | 176 +++ .../circuit-diagrams-new/01-intro/intro.md | 24 + .../02-circuits/_exercises.json | 28 + .../circuit-diagrams-new/02-circuits/_unit.py | 79 ++ .../02-circuits/circuits.ipynb | 203 +++ .../circuit-diagrams-new/02-circuits/intro.md | 25 + courses/circuit-diagrams-new/README.md | 24 + courses/circuit-diagrams-new/_check_env.py | 220 ++++ courses/circuit-diagrams-new/_course_lib.py | 204 +++ courses/circuit-diagrams-new/course.json | 22 + courses/circuit-diagrams-new/pyproject.toml | 10 + source/vscode/ai/qdk-learning.agent.md | 37 +- source/vscode/ai/qdk-learning.prompt.md | 2 +- source/vscode/package.json | 164 +++ source/vscode/src/extension.ts | 8 + source/vscode/src/gh-copilot/learningTools.ts | 78 +- source/vscode/src/gh-copilot/tools.ts | 20 + source/vscode/src/learning/catalog.ts | 21 +- source/vscode/src/learning/commands.ts | 230 +++- source/vscode/src/learning/constants.ts | 9 + source/vscode/src/learning/courseProvider.ts | 98 ++ .../src/learning/dropInCourseProvider.ts | 439 +++++++ source/vscode/src/learning/index.ts | 32 + .../src/learning/notebookCellStatusBar.ts | 60 + source/vscode/src/learning/panel.ts | 119 +- .../vscode/src/learning/progressTreeView.ts | 154 ++- .../vscode/src/learning/python/environment.ts | 491 +++++++ .../src/learning/python/pythonRunner.ts | 182 +++ source/vscode/src/learning/service.ts | 1148 +++++++++++++++-- source/vscode/src/learning/types.d.ts | 145 ++- .../src/learning/webview/webview-client.tsx | 21 +- .../vscode/src/learning/webview/webview.css | 23 + source/vscode/test/buildTests.mjs | 2 + source/vscode/test/runTests.mjs | 4 +- source/vscode/test/suites/extensionUtils.ts | 6 +- .../test/suites/learning/index.browser.ts | 13 + .../vscode/test/suites/learning/index.node.ts | 13 + .../test/suites/learning/learning.test.ts | 95 ++ 41 files changed, 4518 insertions(+), 150 deletions(-) create mode 100644 courses/circuit-diagrams-new/01-intro/_exercises.json create mode 100644 courses/circuit-diagrams-new/01-intro/_unit.py create mode 100644 courses/circuit-diagrams-new/01-intro/intro.ipynb create mode 100644 courses/circuit-diagrams-new/01-intro/intro.md create mode 100644 courses/circuit-diagrams-new/02-circuits/_exercises.json create mode 100644 courses/circuit-diagrams-new/02-circuits/_unit.py create mode 100644 courses/circuit-diagrams-new/02-circuits/circuits.ipynb create mode 100644 courses/circuit-diagrams-new/02-circuits/intro.md create mode 100644 courses/circuit-diagrams-new/README.md create mode 100644 courses/circuit-diagrams-new/_check_env.py create mode 100644 courses/circuit-diagrams-new/_course_lib.py create mode 100644 courses/circuit-diagrams-new/course.json create mode 100644 courses/circuit-diagrams-new/pyproject.toml create mode 100644 source/vscode/src/learning/courseProvider.ts create mode 100644 source/vscode/src/learning/dropInCourseProvider.ts create mode 100644 source/vscode/src/learning/notebookCellStatusBar.ts create mode 100644 source/vscode/src/learning/python/environment.ts create mode 100644 source/vscode/src/learning/python/pythonRunner.ts create mode 100644 source/vscode/test/suites/learning/index.browser.ts create mode 100644 source/vscode/test/suites/learning/index.node.ts create mode 100644 source/vscode/test/suites/learning/learning.test.ts diff --git a/.gitignore b/.gitignore index 3a7e5321556..91f37613181 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,8 @@ __pycache__/ .idea/ *.so samples/scratch/ -samples/qdk-learning/ -samples/qdk-learning.json +qdk-learning/ +qdk-learning.json *.pyd /python_doc/ /logs/ diff --git a/courses/circuit-diagrams-new/01-intro/_exercises.json b/courses/circuit-diagrams-new/01-intro/_exercises.json new file mode 100644 index 00000000000..5a672062693 --- /dev/null +++ b/courses/circuit-diagrams-new/01-intro/_exercises.json @@ -0,0 +1,16 @@ +{ + "exercises": [ + { + "id": "forty_two", + "cellIndex": 7, + "title": "Your first Q# expression", + "description": "Implement the forty_two() function so it returns 42.", + "hints": [ + "The function just needs to return a value equal to the integer 42.", + "The simplest answer is to return the literal `42` itself." + ], + "solution": "@exercise\ndef forty_two():\n return qsharp.eval(\"40 + 2\")", + "solutionExplanation": "Any Q# expression that evaluates to the integer 42 works. The simplest options are the literal `42` or an arithmetic expression like `40 + 2`." + } + ] +} diff --git a/courses/circuit-diagrams-new/01-intro/_unit.py b/courses/circuit-diagrams-new/01-intro/_unit.py new file mode 100644 index 00000000000..a1b069967a3 --- /dev/null +++ b/courses/circuit-diagrams-new/01-intro/_unit.py @@ -0,0 +1,19 @@ +"""Unit helpers — course-infrastructure imports for the notebook.""" + +import sys +from pathlib import Path + +_course_root = str(Path(__file__).resolve().parent.parent) +if _course_root not in sys.path: + sys.path.insert(0, _course_root) + +# Re-export only the course meta-helpers — not the QDK product API. +from _check_env import check as check_env # noqa: E402, F401 +from _course_lib import ( # noqa: E402, F401 + exercise, + register_value_exercise, + complete_unit, +) + +# Register this unit's exercises. +register_value_exercise("forty_two", expected=42) diff --git a/courses/circuit-diagrams-new/01-intro/intro.ipynb b/courses/circuit-diagrams-new/01-intro/intro.ipynb new file mode 100644 index 00000000000..2020ab088f5 --- /dev/null +++ b/courses/circuit-diagrams-new/01-intro/intro.ipynb @@ -0,0 +1,176 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0928290d", + "metadata": {}, + "source": [ + "# Circuit Diagrams: Intro\n", + "\n", + "Welcome to the first unit of the QDK Circuit Diagrams course.\n", + "\n", + "## How to use this notebook\n", + "\n", + "There are four kinds of code cells:\n", + "\n", + "- **Environment check** — run this first. If anything's wrong, follow the instructions in the output.\n", + "- **Example** — run it and observe the output.\n", + "- **Exercise** — these cells contain a function decorated with `@exercise` that you will implement according to instructions. Fill in the missing code, then run the cell to check your work.\n", + "- **Complete this unit** — the last cell. Run it once you've finished all the exercises.\n", + "\n", + "Each cell builds on the ones above it, so work top-to-bottom and run each cell before moving on. You can re-run any cell as many times as you like.\n", + "\n", + "Try the cell below to get started!" + ] + }, + { + "cell_type": "markdown", + "id": "a7d51066", + "metadata": {}, + "source": [ + "## Environment Check\n", + "\n", + "First, let's make sure your Python environment is all set up and contains the packages we'll need for this course. Run the cell below. If the checks fail, follow the instructions in the output to fix any issues." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab1b16cc", + "metadata": {}, + "outputs": [], + "source": [ + "from _unit import check_env\n", + "\n", + "check_env()" + ] + }, + { + "cell_type": "markdown", + "id": "3d2e0171", + "metadata": {}, + "source": [ + "## Example: Running Q# from Python\n", + "\n", + "The QDK lets you write and run Q# code directly from Python. Run the cell below to see it in action — you don't need to edit anything." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b52a1de9", + "metadata": {}, + "outputs": [], + "source": [ + "from qdk import qsharp\n", + "\n", + "# Evaluate a Q# expression. The result is returned as a Python value.\n", + "result = qsharp.eval(\"1 + 1\")\n", + "print(f\"Q# says 1 + 1 = {result}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ac9151de", + "metadata": {}, + "source": [ + "## Exercise: Your first Q# expression\n", + "\n", + "Your turn! Each exercise asks you to implement a function. Edit the function body, then run the cell. The checker will verify your answer.\n", + "\n", + "Edit the `forty_two` function below so it returns `42`, then run the cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db329ce6", + "metadata": {}, + "outputs": [], + "source": [ + "from _unit import exercise\n", + "\n", + "\n", + "@exercise\n", + "def forty_two():\n", + " # ========================================================================\n", + " # YOUR TASK: change the expression below so forty_two() returns 42.\n", + " # ========================================================================\n", + " return qsharp.eval(\"0\") # <-- edit this expression" + ] + }, + { + "cell_type": "markdown", + "id": "d9a84106", + "metadata": {}, + "source": [ + "## Restarting the Python kernel\n", + "\n", + "If you get into a bad state, restart the kernel using the **Restart** action in VS Code. A bad state can happen because all cells share the same Python session: a variable from an earlier cell, a redefined Q# operation, or a half-finished exercise can linger and affect later cells.\n", + "\n", + "Restarting clears the runtime state: variables, imported modules, and defined Q# operations. Any code you wrote or edited will remain untouched. \n", + "\n", + "After restarting, use **Execute Above Cells** (the action button in the top-right corner of any cell) to quickly re-run everything up to where you left off.\n", + "\n", + "## Running all cells at once\n", + "\n", + "You can also click **Run All** — it will run every cell in order and stop at the first incomplete exercise.\n", + "\n", + "Try it now — we'll meet you down here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af2ee582", + "metadata": {}, + "outputs": [], + "source": [ + "raise Exception(\"An exception! Comment out this line and rerun this cell to continue.\")" + ] + }, + { + "cell_type": "markdown", + "id": "72c9c7ef", + "metadata": {}, + "source": [ + "## Complete this unit\n", + "\n", + "Run this cell once you've completed all exercises above. This will mark the unit complete." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4733e5f2", + "metadata": {}, + "outputs": [], + "source": [ + "from _unit import complete_unit\n", + "\n", + "complete_unit()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "qdk", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/courses/circuit-diagrams-new/01-intro/intro.md b/courses/circuit-diagrams-new/01-intro/intro.md new file mode 100644 index 00000000000..e236b08d7e0 --- /dev/null +++ b/courses/circuit-diagrams-new/01-intro/intro.md @@ -0,0 +1,24 @@ +# Getting Started + +Welcome to the first unit of the **Generating Circuit Diagrams** course! + +In this unit you'll learn the basics of running Q# code from Python using the QDK. You'll: + +- Run your first Q# expression from a Python notebook +- Learn how the notebook exercises and verification work +- Practice editing and running cells + +## How it works + +1. Click **Open Notebook** below to open the unit notebook. +2. Work through the cells top-to-bottom — read the instructions, run examples, and fill in exercises. +3. When you've completed all exercises, run the final cell to mark the unit complete. +4. Come back here and click **Next** to continue to the next unit. + +## Before you start + +This course runs in its own Python environment. If the notebook's kernel +won't start, or the first cell reports a problem, set up and check your +environment here first: + +👉 [Check my environment](command:qsharp-vscode.learningDoctor) diff --git a/courses/circuit-diagrams-new/02-circuits/_exercises.json b/courses/circuit-diagrams-new/02-circuits/_exercises.json new file mode 100644 index 00000000000..7f473205f65 --- /dev/null +++ b/courses/circuit-diagrams-new/02-circuits/_exercises.json @@ -0,0 +1,28 @@ +{ + "exercises": [ + { + "id": "cat_circuit", + "cellIndex": 22, + "title": "Render with operation=", + "description": "Implement cat_circuit() to return a circuit built with circuit() using the operation= parameter for PrepareCatState.", + "hints": [ + "The `operation=` parameter takes a string — the name of a Q# operation that accepts only qubits or qubit arrays.", + "Return `circuit(operation=\"PrepareCatState\")`." + ], + "solution": "@exercise\ndef cat_circuit():\n return circuit(operation=\"PrepareCatState\")", + "solutionExplanation": "The `operation=` parameter lets the renderer decide the qubit allocation. Pass the operation name as a string without parentheses or arguments." + }, + { + "id": "flat_circuit", + "cellIndex": 24, + "title": "Flatten a grouped circuit", + "description": "Implement flat_circuit() to return a circuit for GHZ(3) with grouping disabled so each gate is shown individually.", + "hints": [ + "Pass `group_by_scope=False` to `circuit()` to disable grouping.", + "Return `circuit(\"GHZ(3)\", group_by_scope=False)`." + ], + "solution": "@exercise\ndef flat_circuit():\n return circuit(\"GHZ(3)\", group_by_scope=False)", + "solutionExplanation": "Setting `group_by_scope=False` tells the renderer to flatten all operations instead of grouping them by their containing scope (function calls, loops)." + } + ] +} diff --git a/courses/circuit-diagrams-new/02-circuits/_unit.py b/courses/circuit-diagrams-new/02-circuits/_unit.py new file mode 100644 index 00000000000..e3dbc5b84b6 --- /dev/null +++ b/courses/circuit-diagrams-new/02-circuits/_unit.py @@ -0,0 +1,79 @@ +"""Unit helpers — course-infrastructure imports for the notebook.""" + +import json +import sys +from pathlib import Path + +from IPython.display import display + +_course_root = str(Path(__file__).resolve().parent.parent) +if _course_root not in sys.path: + sys.path.insert(0, _course_root) + +from _check_env import check as check_env # noqa: E402, F401 +from _course_lib import ( # noqa: E402, F401 + exercise, + register_exercise, + complete_unit, +) + + +def _missing_gates(circuit, required_gates: list[str]) -> list[str]: + diagram = str(circuit) + return [g for g in required_gates if g not in diagram] + + +def _is_flat(circuit) -> bool: + """True if the circuit has no grouped (nested) operations.""" + data = json.loads(circuit.json()) + operations = data.get("operations", []) + return not any("children" in op for op in operations) + + +def _display_circuit(circuit) -> None: + from qdk.widgets import Circuit + + display(Circuit(circuit)) + + +def register_circuit_exercise( + name: str, *, required_gates: list[str], flat: bool = False +) -> str: + """Register an exercise whose function must return a ``Circuit``. + + Verifies the circuit contains ``required_gates`` (and, when ``flat`` is + True, that no operations are grouped), then displays the widget as + confirmation. The learner returns the circuit; rendering is our job. + """ + + def validate(circuit) -> str | None: + if circuit is None: + return ( + f"{name}() returned None. " + "Did you forget to return the circuit?" + ) + missing = _missing_gates(circuit, required_gates) + if missing: + gate_list = ", ".join(f"{g}" for g in missing) + return ( + f"Your circuit is missing: {gate_list}. " + "Check your code and re-run the cell." + ) + if flat and not _is_flat(circuit): + return ( + "Your circuit still has grouped operations. " + "Did you set group_by_scope=False?" + ) + return None + + return register_exercise( + name, + validate, + success_message=f"Correct! Here's your {name} circuit:", + on_success=_display_circuit, + ) + + +# Register the this unit's exercises. +register_circuit_exercise("cat_circuit", required_gates=["H", "X"]) +register_circuit_exercise("flat_circuit", required_gates=["H", "X"], flat=True) diff --git a/courses/circuit-diagrams-new/02-circuits/circuits.ipynb b/courses/circuit-diagrams-new/02-circuits/circuits.ipynb new file mode 100644 index 00000000000..87b1fa390a5 --- /dev/null +++ b/courses/circuit-diagrams-new/02-circuits/circuits.ipynb @@ -0,0 +1,203 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3db5183b", + "metadata": {}, + "source": [ + "# Rendering Circuit Diagrams\n", + "\n", + "In this unit you'll learn how to use the `circuit()` API and the `Circuit` widget to generate and display circuit diagrams from Q# operations.\n", + "\n", + "We'll assume you're already familiar with Q# and quantum circuits. The focus here is on the **Python API** — what options are available and how they affect the rendered output.\n" + ] + }, + { + "cell_type": "markdown", + "id": "bda994e6", + "metadata": {}, + "source": [ + "## Environment Check\n", + "\n", + "Run the cell below to verify your environment is set up correctly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aaf39e45", + "metadata": {}, + "outputs": [], + "source": [ + "from _unit import check_env\n", + "\n", + "check_env()" + ] + }, + { + "cell_type": "markdown", + "id": "34989e87", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's import the tools we'll use and define some Q# operations to work with throughout this unit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c2e67c9", + "metadata": {}, + "outputs": [], + "source": [ + "from qdk import qsharp\n", + "from qdk.qsharp import circuit\n", + "from qdk.widgets import Circuit\n", + "\n", + "# Define a few operations we'll use throughout this unit.\n", + "OPERATIONS = \"\"\"\n", + " operation BellPair() : (Result, Result) {\n", + " use (a, b) = (Qubit(), Qubit());\n", + " H(a);\n", + " CNOT(a, b);\n", + " return (M(a), M(b));\n", + " }\n", + "\n", + " operation GHZ(n : Int) : Result[] {\n", + " use qs = Qubit[n];\n", + " H(qs[0]);\n", + " for i in 1..n-1 {\n", + " CNOT(qs[0], qs[i]);\n", + " }\n", + " let results = MeasureEachZ(qs);\n", + " ResetAll(qs);\n", + " return results;\n", + " }\n", + "\n", + " operation PrepareCatState(qs : Qubit[]) : Unit {\n", + " H(qs[0]);\n", + " for i in 1..Length(qs)-1 {\n", + " CNOT(qs[0], qs[i]);\n", + " }\n", + " }\n", + "\"\"\"\n", + "\n", + "qsharp.eval(OPERATIONS)\n", + "\n", + "print(\"Operations defined.\")" + ] + }, + { + "cell_type": "markdown", + "id": "09c7e760", + "metadata": {}, + "source": [ + "## Example: Basic circuit generation with an entry expression\n", + "\n", + "`circuit()` takes an **entry expression** — a string that calls a Q# operation — and returns a `Circuit` object. Wrap it in the `Circuit` widget to render it in the notebook.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab1207cb", + "metadata": {}, + "outputs": [], + "source": [ + "# Generate a circuit from an entry expression\n", + "bell = circuit(\"BellPair()\")\n", + "Circuit(bell)\n" + ] + }, + { + "cell_type": "markdown", + "id": "d0e0ed73", + "metadata": {}, + "source": [ + "## Exercise: Render with `operation=`\n", + "\n", + "Use `circuit()` with the `operation=` parameter to generate a circuit for the `PrepareCatState` operation we defined earlier. Return it from `cat_circuit()`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12d649d7", + "metadata": {}, + "outputs": [], + "source": [ + "from _unit import exercise\n", + "\n", + "\n", + "@exercise\n", + "def cat_circuit():\n", + " # ========================================================================\n", + " # YOUR TASK: use circuit() with operation= to build the circuit for\n", + " # PrepareCatState, and return it.\n", + " # ========================================================================\n", + " return None # <-- replace this" + ] + }, + { + "cell_type": "markdown", + "id": "c4d8a1e1", + "metadata": {}, + "source": [ + "## Exercise: Flatten a grouped circuit\n", + "\n", + "Generate a circuit for `GHZ(3)` with grouping **disabled**, so each gate is shown individually. Assign the result to `flat_circuit`. Return it from `flat_circuit()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8d8aca1", + "metadata": {}, + "outputs": [], + "source": [ + "from _unit import exercise\n", + "\n", + "\n", + "@exercise\n", + "def flat_circuit():\n", + " # ========================================================================\n", + " # YOUR TASK: generate a circuit for GHZ(3) with group_by_scope=False,\n", + " # and return it.\n", + " # ========================================================================\n", + " return None # <-- replace this" + ] + }, + { + "cell_type": "markdown", + "id": "3d81eb01", + "metadata": {}, + "source": [ + "## Complete this unit\n", + "\n", + "You've learned how to use `circuit()` to generate diagrams from entry expressions and qubit-only operations, and how to control grouping with `group_by_scope`.\n", + "\n", + "Run the cell below to mark the unit complete.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68d48e2e", + "metadata": {}, + "outputs": [], + "source": [ + "from _unit import complete_unit\n", + "\n", + "complete_unit()" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/courses/circuit-diagrams-new/02-circuits/intro.md b/courses/circuit-diagrams-new/02-circuits/intro.md new file mode 100644 index 00000000000..677e0767d74 --- /dev/null +++ b/courses/circuit-diagrams-new/02-circuits/intro.md @@ -0,0 +1,25 @@ +# Circuit Diagrams + +In this unit you'll learn how to generate and display circuit diagrams from Q# operations using the Python API. + +You'll explore: + +- Rendering circuits with `qsharp.circuit()` and the `Circuit` widget +- Using `operation=` for qubit-only operations +- Controlling grouping with `group_by_scope` +- Handling measurement-based conditionals with `generation_method` +- Limiting output with `max_operations` + +## How it works + +1. Click **Open Notebook** below to open the unit notebook. +2. Work through the examples and exercises in order. +3. Run the final cell to mark the unit complete, then come back here. + +## Before you start + +This course runs in its own Python environment. If the notebook's kernel +won't start, or the first cell reports a problem, set up and check your +environment here first: + +👉 [Check my environment](command:qsharp-vscode.learningDoctor) diff --git a/courses/circuit-diagrams-new/README.md b/courses/circuit-diagrams-new/README.md new file mode 100644 index 00000000000..bdd56f62442 --- /dev/null +++ b/courses/circuit-diagrams-new/README.md @@ -0,0 +1,24 @@ +# Generating Circuit Diagrams + +A short, hands-on course that shows how to generate and visualize circuit +diagrams from Q# operations using the QDK Python API in a Jupyter notebook. + +## What you'll learn + +- Run Q# code from Python with the `qdk` package. +- Generate a circuit from a Q# operation with `qdk.qsharp.circuit()`. +- Display an interactive circuit diagram with the `qdk.widgets.Circuit` widget. +- Control the rendered output with options like `operation=` and + `group_by_scope`. + +## Requirements + +This course runs in a per-course Python environment that pins: + +- `qdk[jupyter]` +- `ipympl` +- `ipykernel` + +The course environment is created automatically the first time you open the +course. If anything looks wrong, run **QDK Learning: Run Course Diagnostics** +to diagnose it. diff --git a/courses/circuit-diagrams-new/_check_env.py b/courses/circuit-diagrams-new/_check_env.py new file mode 100644 index 00000000000..388e1250bdb --- /dev/null +++ b/courses/circuit-diagrams-new/_check_env.py @@ -0,0 +1,220 @@ +"""Course environment check utility. + +Called from the first code cell of each unit notebook. Validates that the +notebook kernel is running in the course .venv and that all required packages +are importable. Renders results as styled HTML in the notebook output. +""" + +import importlib.util +import json +import sys +from pathlib import Path + +from IPython.display import HTML, display + + +def check(notebook_dir: str | Path | None = None) -> None: + """Run the environment check and display results. + + Raises EnvironmentError if anything is wrong, which stops "Run All" + from continuing past this cell. + + Parameters + ---------- + notebook_dir : path-like, optional + Directory containing the notebook. Defaults to Path.cwd(). + """ + nb_dir = Path(notebook_dir) if notebook_dir else Path.cwd() + + # --- Locate course.json --- + course_json = _find_course_json(nb_dir) + if course_json is None: + raise FileNotFoundError( + "Could not find course.json. Make sure you opened this notebook " + "from the QDK course folder." + ) + + course = json.loads(course_json.read_text()) + env_cfg = course.get("environment", {}) + requirements = env_cfg.get("requirements", []) + import_checks = env_cfg.get("importChecks", []) + + results: list[tuple[str, str, bool]] = [] # (label, detail, ok) + errors: list[str] = [] + + # --- Check 1: Python version --- + py_version = sys.version.split()[0] + results.append(("Python version", py_version, True)) + + # --- Check 2: course .venv exists and has a Python interpreter --- + course_root = course_json.resolve().parent + expected_venv = (course_root / ".venv").resolve() + venv_exists = expected_venv.is_dir() + venv_python = _find_venv_python(expected_venv) if venv_exists else None + + if not venv_exists: + results.append(("Course venv", f"{expected_venv} — not found", False)) + errors.append( + "The course virtual environment does not exist yet.
" + "Run QDK Learning: Doctor from the Command Palette " + "(Ctrl+Shift+P / Cmd+Shift+P) " + "and choose Set up environment." + + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") + ) + elif not venv_python: + results.append(("Course venv", f"{expected_venv} — corrupt (no python)", False)) + errors.append( + "The course virtual environment exists but has no Python interpreter.
" + "Run QDK Learning: Doctor from the Command Palette " + "and choose Set up environment to recreate it." + + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") + ) + else: + results.append(("Course venv", str(expected_venv), True)) + + # --- Check 3: kernel is actually using the course .venv --- + prefix = Path(sys.prefix).resolve() + + in_course_venv = False + if venv_exists: + try: + prefix.relative_to(expected_venv) + in_course_venv = True + except ValueError: + pass + + if venv_exists and venv_python and not in_course_venv: + results.append(("Kernel", f"Expected {expected_venv}, got {prefix}", False)) + errors.append( + "This kernel is not the course environment. " + "Click Select Kernel (top-right of the notebook) " + "and pick the course .venv, then re-run this cell." + ) + + # --- Check 4: required packages --- + missing = [m for m in import_checks if importlib.util.find_spec(m) is None] + + if missing: + results.append( + ("Packages", ", ".join(f"{m} missing" for m in missing), False) + ) + # Check if this course uses pyproject.toml (uv sync) or legacy requirements. + has_pyproject = (course_root / "pyproject.toml").exists() + if has_pyproject: + errors.append( + "Some packages are missing from the course environment.
" + "Run QDK Learning: Doctor to re-sync, or manually run " + "uv sync in the course folder." + + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") + ) + else: + pip_cmd = f"%pip install {' '.join(requirements)}" + errors.append( + "Install missing packages by running this in a new cell, then re-run this one:" + f"
  {pip_cmd}
" + "Or run QDK Learning: Doctor to set up the full environment." + + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") + ) + elif import_checks and in_course_venv: + results.append(("Packages", ", ".join(import_checks), True)) + + # --- Render --- + _render(results, errors) + + if errors: + raise EnvironmentError( + "Environment check failed. See output above for details." + ) + + +def _find_venv_python(venv: Path) -> Path | None: + """Return the venv's Python interpreter path, or None if missing.""" + candidates = [ + venv / "bin" / "python", + venv / "bin" / "python3", + venv / "Scripts" / "python.exe", + ] + for c in candidates: + if c.exists(): + return c + return None + + +def _command_link(command_id: str, label: str) -> str: + """Return an HTML link that invokes a VS Code command when clicked. + + VS Code renders `vscode://` and `command:` URIs in trusted notebook + HTML output, so clicking the link runs the command directly. + """ + from urllib.parse import quote + + return ( + f'
' + f"{label}" + ) + + +def _find_course_json(nb_dir: Path) -> Path | None: + """Walk up from nb_dir looking for course.json.""" + candidate = nb_dir / "course.json" + if candidate.exists(): + return candidate + # One level up (unit notebook inside a subdirectory). + candidate = (nb_dir / ".." / "course.json").resolve() + if candidate.exists(): + return candidate + # Two levels up (deeply nested unit). + candidate = (nb_dir / ".." / ".." / "course.json").resolve() + if candidate.exists(): + return candidate + return None + + +def _render(results: list[tuple[str, str, bool]], errors: list[str]) -> None: + """Display a styled HTML summary.""" + rows = "" + for label, detail, ok in results: + icon = "✅" if ok else "❌" + color = "#2e7d32" if ok else "#c62828" + rows += ( + f'' + f'{icon}' + f'{label}' + f'{detail}' + f"" + ) + + html = ( + '
' + '' + f"{rows}" + "
" + ) + + if errors: + error_items = "".join(f"
  • {e}
  • " for e in errors) + html += ( + '
    ' + f"Action needed:
      {error_items}
    " + "
    " + ) + else: + html += ( + '
    ' + "Environment looks good. You're ready to continue!" + "
    " + ) + + html += "
    " + display(HTML(html)) + + +if __name__ == "__main__": + check() diff --git a/courses/circuit-diagrams-new/_course_lib.py b/courses/circuit-diagrams-new/_course_lib.py new file mode 100644 index 00000000000..fe3ab2d19dc --- /dev/null +++ b/courses/circuit-diagrams-new/_course_lib.py @@ -0,0 +1,204 @@ +"""Shared course utilities — the exercise harness and unit completion. + +This module lives at the course root. Per-unit helper files (`_unit.py`) import +from it and re-export the small surface the notebooks need. + +The exercise model +------------------ +Learners solve an exercise by implementing a function decorated with ``@exercise``. + +A unit registers a checker for each exercise *by function name* (see +``register_value_exercise`` / ``register_circuit_exercise``). When the learner +runs their decorated cell, ``exercise`` looks up the matching checker, calls the +learner's function, validates the result and renders a pass/fail banner and other +relevant visuals or output. +""" + +import re +from pathlib import Path +from typing import Callable + +from IPython.display import HTML, display + +# Registry of exercises that have passed in this kernel session. +_passed: set[str] = set() + +# A checker takes the learner's function and verifies it. +Checker = Callable[[Callable[[], object]], None] + +# Registry of checkers, keyed by exercise function name. +_checkers: dict[str, Checker] = {} + +# Exercise names in registration order. A unit's required set is derived from +# this, so each exercise name is written exactly once (in its register call). +_registered: list[str] = [] + + +def _register(name: str, checker: Checker) -> str: + """Record a checker under ``name`` and return the name.""" + _checkers[name] = checker + if name not in _registered: + _registered.append(name) + return name + + +# --------------------------------------------------------------------------- +# Rendering helpers +# --------------------------------------------------------------------------- + + +def _pass(message: str) -> None: + """Render a green success banner.""" + display( + HTML( + '
    ' + f"✅ {message}" + "
    " + ) + ) + + +def _fail(message: str) -> None: + """Render an orange failure banner and raise AssertionError.""" + display( + HTML( + '
    ' + f"❌ {message}" + "
    " + ) + ) + raise AssertionError(message) + + +# --------------------------------------------------------------------------- +# The exercise decorator +# --------------------------------------------------------------------------- + + +def exercise(fn): + """Decorator for a learner's exercise function. + + Looks up the checker registered for ``fn.__name__`` and runs it. The + learner just writes the function body and a ``return`` — running the cell + runs the verification. + """ + checker = _checkers.get(fn.__name__) + if checker is None: + _fail( + f"No checker is registered for an exercise named " + f"{fn.__name__}. Don't rename the function — " + "it must keep the name we gave you." + ) + return fn + checker(fn) + return fn + + +def _run(fn): + """Call the learner's function, surfacing errors as a failure banner.""" + try: + return fn() + except Exception as e: # noqa: BLE001 — surface any learner error nicely + _fail( + f"Your {fn.__name__} function raised an error: " + f"{type(e).__name__}: {e}" + ) + + +# --------------------------------------------------------------------------- +# Value exercises +# --------------------------------------------------------------------------- + + +def register_value_exercise(name: str, *, expected) -> str: + """Register an exercise whose function must return ``expected``.""" + + def checker(fn) -> None: + actual = _run(fn) + if actual != expected: + _fail( + f"{name}() returned {actual!r}, " + f"but expected {expected!r}." + ) + else: + _passed.add(name) + _pass(f"Correct! {name}() returned {actual!r}.") + + return _register(name, checker) + + +# --------------------------------------------------------------------------- +# Custom exercises +# --------------------------------------------------------------------------- + + +def register_exercise( + name: str, + validate: Callable[[object], str | None], + *, + success_message: str = "Correct!", + on_success: Callable[[object], None] | None = None, +) -> str: + """Register an exercise with a unit-defined validation function. + + Use this when a unit needs bespoke checking that isn't covered by the + generic helpers above. ``validate(result)`` inspects the value returned by + the learner's function and returns an HTML error message if it's wrong, or + ``None`` if it's correct. On success a banner with ``success_message`` is + shown, ``on_success(result)`` is called (e.g. to display a widget), and the + exercise is recorded as passed. + """ + + def checker(fn) -> None: + result = _run(fn) + error = validate(result) + if error: + _fail(error) + return + _passed.add(name) + _pass(success_message) + if on_success is not None: + on_success(result) + + return _register(name, checker) + + +# --------------------------------------------------------------------------- +# Unit completion +# --------------------------------------------------------------------------- + + +def complete_unit(required_exercises: list[str] | None = None) -> None: + """Verify all exercises passed and write the unit-complete marker. + + When ``required_exercises`` is omitted, every exercise registered in this + kernel session is required — i.e. all of the current unit's exercises. + """ + if required_exercises is None: + required_exercises = _registered + missing = [e for e in required_exercises if e not in _passed] + if missing: + names = ", ".join(f"`{e}`" for e in missing) + raise AssertionError( + f"Not all exercises are complete. Missing: {names}. " + "Run the exercise cells above first." + ) + + unit_id = re.sub(r"^\d+-", "", Path.cwd().name) + + marker = Path(".qdk-unit-complete") + marker.write_text(f"{unit_id}\n") + + display( + HTML( + '
    ' + "🎉 Congratulations — you've completed this unit!" + "
    " + ) + ) diff --git a/courses/circuit-diagrams-new/course.json b/courses/circuit-diagrams-new/course.json new file mode 100644 index 00000000000..fae2935a230 --- /dev/null +++ b/courses/circuit-diagrams-new/course.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "circuit-diagrams", + "title": "Generating Circuit Diagrams", + "shortDescription": "Build and visualize quantum circuits with the QDK in Python notebooks.", + "readme": "README.md", + "units": [ + { + "id": "intro", + "title": "Getting Started", + "dir": "01-intro" + }, + { + "id": "circuits", + "title": "Circuit Diagrams", + "dir": "02-circuits" + } + ], + "environment": { + "importChecks": ["qdk", "qdk.widgets"] + } +} diff --git a/courses/circuit-diagrams-new/pyproject.toml b/courses/circuit-diagrams-new/pyproject.toml new file mode 100644 index 00000000000..fe6d380b5a5 --- /dev/null +++ b/courses/circuit-diagrams-new/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "circuit-diagrams" +version = "0.1.0" +description = "QDK Course: Generating Circuit Diagrams" +requires-python = ">=3.11" +dependencies = [ + "qdk[jupyter]>=1.29", + "ipympl>=0.10", + "ipykernel>=7.3", +] diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index f093fc1f52a..f6db18a5c97 100644 --- a/source/vscode/ai/qdk-learning.agent.md +++ b/source/vscode/ai/qdk-learning.agent.md @@ -1,22 +1,24 @@ --- name: QDK Learning -description: "Learn quantum computing interactively with the Quantum Katas — guided lessons, hands-on exercises, and Q# code you can run, check, and explore right in VS Code." +description: "Learn quantum computing interactively in VS Code — guided lessons, hands-on exercises, and code you can run, check, and explore. Includes the Quantum Katas and other learning courses." model: "Claude Haiku 4.5 (copilot)" --- # Quantum Development Kit Learning -You are an agent that helps users navigate and interact with the Quantum Katas panel in VS Code. Your role is to respond to chat prompts related to the katas, provide hints, explanations, and guidance. +You are an agent that helps users navigate and interact with the QDK Learning feature in VS Code. Your role is to respond to chat prompts related to the active course, provide hints, explanations, and guidance. -The `qdk-learning-*` tools drive a **Quantum Katas panel** in VS Code. The panel renders the current activity, action bar, and progress bar. Its buttons handle navigation, run, check, etc. directly — they bypass the LLM. Your job: set up the workspace, show the current activity, then step aside. You only handle chat prompts and concept questions. +The `qdk-learning-*` tools drive the QDK Learning UI in VS Code. The Lesson panel renders the current activity, action bar, and progress bar. Its buttons handle navigation, run, check, etc. directly — they bypass the LLM. Your job: set up the workspace, show the current activity, then step aside. You only handle chat prompts and concept questions. + +A user can work through more than one **course**. The **Quantum Katas** is the default course. Additional courses may also be available in the workspace. Each course has its own units, activities, and progress. ## Definitions -Following is a user-ready description of the Quantum Katas. You may refer to it if the user asks what the katas are or how they work. +The **Quantum Katas** is the flagship course. Following is a user-ready description. You may refer to it if the user asks what the katas are or how they work. > Quantum Katas (_kaˑta_ | kah-tuh — Japanese for "form", a pattern of learning and practicing new skills) are self-paced, AI-assisted tutorials for quantum computing and Q# programming. Each tutorial includes relevant theory and interactive hands-on exercises designed to test knowledge. -The tools refer to each kata as a "unit." Each unit contains ordered activities (lessons, examples, exercises). +The tools refer to each unit of a course as a "unit." Each unit contains ordered activities (lessons, examples, exercises). **Tool naming:** All learning tools share the `qdk-learning-` prefix. This document uses short names (e.g. `show` for `qdk-learning-show`). @@ -28,13 +30,31 @@ The tools refer to each kata as a "unit." Each unit contains ordered activities ## Startup -Call `get-state` first. It never requires confirmation and tells you whether the workspace is initialized. +Call `get-state` first. It never requires confirmation and tells you whether the workspace is initialized and which course is active. -- **If `initialized: true`** — you have the current position and progress. Greet the user briefly, then call `show` to open the activity panel. Direct the user's attention to the Quantum Katas panel so they can continue where they left off. +- **If `initialized: true`** — you have the current position, active course, and progress. Greet the user briefly, then call `show` to open the activity panel. Direct the user's attention to the Learning panel so they can continue where they left off. - **If `initialized: false`** — the workspace hasn't been set up yet. Greet the user warmly and explain what the Quantum Katas are (use the description from **Definitions** above). Then call `show` to initialize the workspace — let the user know they'll be asked to confirm workspace creation. Once initialized, direct them to the panel to get started. Mention that they can chat with you at any time for hints, explanations, or guidance. Don't explain how the agent works, list tools, or show menus. +## Courses + +Multiple courses may be available. The active course is reported by `get-state` (the `course` field) and is the context for all activity, run, and check operations. The **Quantum Katas** is the default course. + +| Intent | Tool | Notes | +| ------------------------------------- | --------------- | ------------------------------------------------------------------------- | +| "What courses are available?" | `list-courses` | Returns the available courses and the active course id. | +| "Switch to …" / "Open the … course" | `switch-course` | Pass the `courseId`. Switching changes the active course and position. | +| "Tell me about this course" | `course-info` | Returns the course descriptor and README (defaults to the active course). | +| "Diagnose" / "Set up the environment" | `doctor` | Runs environment diagnostics for the active course (Python courses). | + +**Handling guidance:** + +- When the user asks to change courses, call `list-courses` first if you're unsure of the exact `courseId`, match the user's request to a course, then call `switch-course`. After switching, call `show` to surface the new course's current activity and briefly tell the user where they landed. +- Drop-in courses run author-provided code and only load in a **trusted** workspace. If a drop-in course doesn't appear or won't run, the workspace may be in Restricted Mode — suggest trusting the workspace. +- Python notebook courses use a per-course environment. If running or checking a task reports environment or kernel problems, call `doctor` to diagnose; it reports which checks fail and whether a one-click setup can fix them. Q# courses need no environment and always pass `doctor`. +- Don't switch courses unless the user clearly asks. Panel and tree actions can also switch courses without involving you, so always call `get-state` to learn the current course before answering. + ## Tone Warm, friendly tutor. Celebrate passes, encourage on failures, use natural language. @@ -68,6 +88,8 @@ Call `show`. Use the returned state for your greeting. Don't call on every turn To start a specific unit: `list-units` → find `unitId` → `goto`. +To change courses: `list-courses` → find `courseId` → `switch-course` → `show`. + ### 2. Route Chat Input Call `get-state` first. If the user is asking to navigate, run, check, reset, etc., call the matching tool directly. Notable cases: @@ -75,6 +97,7 @@ Call `get-state` first. If the user is asking to navigate, run, check, reset, et - **hint** → use the **Hint Strategy** below instead of just calling the tool - **solution** → warn about spoilers before calling - **reset** → confirm the user wants to lose their code before calling +- **switch course / list courses / course info** → use the **Courses** tools (`switch-course`, `list-courses`, `course-info`); call `show` after a switch - **"help with my code" / "debug"** → call `read-code`, then give personalized feedback - **Q# or QDK question** → if the answer isn't obvious from the current lesson context, **always** read the `/qdk-programming` skill before responding. - **free-form question** → answer using knowledge + current state; no tool needed diff --git a/source/vscode/ai/qdk-learning.prompt.md b/source/vscode/ai/qdk-learning.prompt.md index 852684a6423..5b8ba044ad8 100644 --- a/source/vscode/ai/qdk-learning.prompt.md +++ b/source/vscode/ai/qdk-learning.prompt.md @@ -5,4 +5,4 @@ agent: QDK Learning argument-hint: Chat with the QDK Learning agent. e.g. "give me a hint", "check my solution", "run my code" --- -Let's do the Quantum Katas. +Let's learn quantum computing with the QDK. Start with the Quantum Katas, or switch to another available course. diff --git a/source/vscode/package.json b/source/vscode/package.json index a0d32a20924..6f9228c5595 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -341,6 +341,22 @@ { "command": "qsharp-vscode.learningAskInChat", "when": "false" + }, + { + "command": "qsharp-vscode.learningSwitchCourse", + "when": "false" + }, + { + "command": "qsharp-vscode.learningCourseInfo", + "when": "false" + }, + { + "command": "qsharp-vscode.learningCheckEnvironment", + "when": "qsharp-vscode.learningWorkspaceDetected" + }, + { + "command": "qsharp-vscode.learningNotebookHint", + "when": "false" } ], "view/title": [ @@ -371,6 +387,28 @@ } ], "view/item/context": [ + { + "command": "qsharp-vscode.learningSwitchCourse", + "group": "inline", + "when": "view == qsharp-vscode.learningTree && (viewItem == course || viewItem == coursePython)" + }, + { + "command": "qsharp-vscode.learningCourseInfo", + "group": "inline", + "when": "view == qsharp-vscode.learningTree && (viewItem == course || viewItem == coursePython)" + }, + { + "command": "qsharp-vscode.learningSwitchCourse", + "when": "view == qsharp-vscode.learningTree && (viewItem == course || viewItem == coursePython)" + }, + { + "command": "qsharp-vscode.learningCourseInfo", + "when": "view == qsharp-vscode.learningTree && (viewItem == course || viewItem == coursePython)" + }, + { + "command": "qsharp-vscode.learningCheckEnvironment", + "when": "view == qsharp-vscode.learningTree && viewItem == coursePython" + }, { "command": "qsharp-vscode.workspaceOpenPortal", "group": "inline", @@ -420,6 +458,20 @@ "when": "view == qsharp-vscode.learningTree && (viewItem == continue || viewItem == unit || viewItem == lesson || viewItem == exercise || viewItem == example)" } ], + "notebook/toolbar": [ + { + "command": "qsharp-vscode.learningNotebookHint", + "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", + "group": "navigation@100" + } + ], + "notebook/cell/title": [ + { + "command": "qsharp-vscode.learningNotebookHint", + "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected && notebookCellType == 'code'", + "group": "inline/cell@50" + } + ], "explorer/context": [ { "command": "qsharp-vscode.createProject", @@ -699,6 +751,30 @@ "title": "Show Current Activity", "category": "QDK Learning", "icon": "$(mortar-board)" + }, + { + "command": "qsharp-vscode.learningSwitchCourse", + "title": "Switch Course", + "category": "QDK Learning", + "icon": "$(arrow-swap)" + }, + { + "command": "qsharp-vscode.learningCourseInfo", + "title": "Course Info", + "category": "QDK Learning", + "icon": "$(info)" + }, + { + "command": "qsharp-vscode.learningCheckEnvironment", + "title": "Run Course Diagnostics", + "category": "QDK Learning", + "icon": "$(pulse)" + }, + { + "command": "qsharp-vscode.learningNotebookHint", + "title": "Ask for a Hint", + "category": "QDK Learning", + "icon": "$(comment-discussion-sparkle)" } ], "breakpoints": [ @@ -1331,6 +1407,94 @@ "additionalProperties": false } }, + { + "name": "qdk-learning-list-courses", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningListCourses", + "displayName": "QDK Learning: List Courses", + "modelDescription": "List all available learning courses (loaded or not) with their ids, titles, kinds, and the id of the currently-active course. Use the course ids with switch-course or goto.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-switch-course", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningSwitchCourse", + "displayName": "QDK Learning: Switch Course", + "modelDescription": "Switch the active learning course. Moves to the first incomplete activity in that course and updates the panel. Use a courseId from list-courses.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": { + "courseId": { + "type": "string", + "description": "ID of the course to switch to (from list-courses)." + } + }, + "required": [ + "courseId" + ], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-course-info", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningCourseInfo", + "displayName": "QDK Learning: Course Info", + "modelDescription": "Return the descriptor and README content (if any) for a course. Defaults to the active course when no courseId is provided.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": { + "courseId": { + "type": "string", + "description": "ID of the course. Omit for the active course." + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-check-environment", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningCheckEnvironment", + "displayName": "QDK Learning: Check Environment", + "modelDescription": "Run environment diagnostics for the active learning course. Returns structured checks (Python interpreter, virtual environment, fingerprint, required packages) and whether a one-click environment setup can fix any failures. Q# courses need no environment and pass trivially.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, { "name": "qdk-learning-next", "tags": [ diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index 2e222bd3955..8ba91954570 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -17,6 +17,7 @@ import { startOtherQSharpDiagnostics } from "./diagnostics.js"; import { removeDeprecatedCopilotInstructions } from "./gh-copilot/instructions.js"; import { registerLanguageModelTools } from "./gh-copilot/tools.js"; import { initLearning } from "./learning/index.js"; +import type { LearningService } from "./learning/index.js"; import { activateLanguageService } from "./language-service/activate.js"; import { Logging, @@ -104,6 +105,11 @@ export async function activate( if (vscode.env.uiKind !== vscode.UIKind.Web) { const learningService = initLearning(context); registerLanguageModelTools(context, learningService); + if (context.extensionMode === vscode.ExtensionMode.Test) { + // Test-only seam: expose the learning service so integration tests can + // drive multi-course flows without UI automation. + api.learning = learningService; + } } // fire-and-forget removeDeprecatedCopilotInstructions(context); @@ -214,6 +220,8 @@ export interface ExtensionApi { // Only available in test mode. Allows listening to extension log events. logging?: Logging; setGithubEndpoint: (endpoint: string) => void; + // Only available in test mode on desktop. The multi-course learning service. + learning?: LearningService; } export class QsTextDocumentContentProvider diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 0a707835eb5..82e3752c27e 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -7,6 +7,8 @@ import { LEARNING_WORKSPACE_FOLDER, detectLearningWorkspace, resolveNewWorkspaceRoot, + type CourseDescriptor, + type EnvironmentCheckReport, type HintContext, type UnitSummary, type OverallProgress, @@ -24,6 +26,8 @@ import { CopilotToolError } from "./types.js"; * curriculum without needing a separate round-trip. */ export interface SerializedLearningState { + /** The currently-active course. */ + course: { id: string; title: string; kind: string }; position: CurrentActivity; progress: { totalActivities: number; @@ -137,12 +141,83 @@ export class LearningTools { } /** - * Read the user's current Q# code at the active exercise or example. + * List all available courses (loaded or not) with the active course id. + */ + async listCourses(): Promise<{ + courses: CourseDescriptor[]; + activeCourseId: string; + }> { + await this.ensureInitialized(); + return { + courses: await this.service.getCourses(), + activeCourseId: this.service.getActiveCourseId(), + }; + } + + /** + * Switch the active course, moving to its first incomplete activity. + */ + async switchCourse(input: { courseId: string }): Promise { + await this.ensureInitialized(); + return this.invoke(async () => { + await this.service.switchCourse(input.courseId, "chat"); + await this.showActivity(); + return { state: this.serializeState() }; + }); + } + + /** + * Return descriptor and README content (if any) for a course. Defaults + * to the active course when no id is provided. + */ + async courseInfo(input?: { courseId?: string }): Promise<{ + descriptor: CourseDescriptor | undefined; + readme?: string; + }> { + await this.ensureInitialized(); + return this.invoke(async () => { + const courseId = input?.courseId ?? this.service.getActiveCourseId(); + const courses = await this.service.getCourses(); + const descriptor = courses.find((c) => c.id === courseId); + let readme: string | undefined; + if (descriptor?.readmePath) { + try { + const bytes = await vscode.workspace.fs.readFile( + vscode.Uri.parse(descriptor.readmePath), + ); + readme = new TextDecoder().decode(bytes); + } catch { + readme = undefined; + } + } + return { descriptor, readme }; + }); + } + + /** + * Run environment diagnostics for the active course and return the + * structured report (passing/failing checks plus whether a one-click + * environment setup is available). + */ + async checkEnvironment(): Promise { + await this.ensureInitialized(); + return this.invoke(() => this.service.runEnvironmentCheck()); + } + + /** + * Read the user's current code at the active exercise or example. + * For python-notebook courses, returns the notebook file path. */ async readCode(): Promise<{ code: string; filePath: string }> { await this.ensureInitialized(); return this.invoke(async () => { const uri = this.getCurrentFileUri(); + if (this.service.getActiveCourseInfo().kind === "python-notebook") { + return { + code: "", + filePath: uri.fsPath, + }; + } const code = await this.service.readUserCode(); return { code, filePath: uri.fsPath }; }); @@ -311,6 +386,7 @@ export class LearningTools { : undefined; return { + course: this.service.getActiveCourseInfo(), position: state.position, progress: { totalActivities: progress.stats.totalActivities, diff --git a/source/vscode/src/gh-copilot/tools.ts b/source/vscode/src/gh-copilot/tools.ts index 11ab813a242..f0c55374221 100644 --- a/source/vscode/src/gh-copilot/tools.ts +++ b/source/vscode/src/gh-copilot/tools.ts @@ -125,6 +125,26 @@ const toolDefinitions: { tool: async () => await learningTools!.listUnits(), confirm: async () => learningTools!.confirmInit(), }, + { + name: "qdk-learning-list-courses", + tool: async () => await learningTools!.listCourses(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-switch-course", + tool: async (input) => await learningTools!.switchCourse(input), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-course-info", + tool: async (input) => await learningTools!.courseInfo(input), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-check-environment", + tool: async () => await learningTools!.checkEnvironment(), + confirm: async () => learningTools!.confirmInit(), + }, { name: "qdk-learning-next", tool: async () => await learningTools!.next(), diff --git a/source/vscode/src/learning/catalog.ts b/source/vscode/src/learning/catalog.ts index d313e100aa6..e1638cbca14 100644 --- a/source/vscode/src/learning/catalog.ts +++ b/source/vscode/src/learning/catalog.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { getAllKatas } from "qsharp-lang/katas-md"; +import * as vscode from "vscode"; import { KATAS_COURSE_ID } from "./constants.js"; +import { CourseRegistry, KatasProvider } from "./courseProvider.js"; +import { DropInCourseProvider } from "./dropInCourseProvider.js"; import type { CatalogUnit, CatalogCourse, @@ -80,5 +83,21 @@ export async function loadKatasCourse(): Promise { }), })); - return { id: KATAS_COURSE_ID, title: "Quantum Katas", units }; + return { id: KATAS_COURSE_ID, title: "Quantum Katas", kind: "qsharp", units }; +} + +/** + * Create the {@link CourseRegistry} with all available course providers. + * + * Registers the built-in Quantum Katas provider plus a + * {@link DropInCourseProvider} that discovers courses authored on disk + * (under `qdk-learning/courses/*`). + */ +export function createCourseRegistry( + workspaceRoot: vscode.Uri, +): CourseRegistry { + return new CourseRegistry([ + new KatasProvider(), + new DropInCourseProvider(workspaceRoot), + ]); } diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index b0ccdcbf2ef..c96a6885888 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -59,7 +59,10 @@ export function registerLearningCommands( vscode.commands.registerCommand( "qsharp-vscode.learningContinue", async () => { - // No position recorded yet — open chat with a generic start prompt. + // Initialize the workspace before opening chat so the agent + // finds it already set up and skips the confirmation prompt. + await service.tryInitialize({ createIfMissing: true }); + await vscode.commands.executeCommand("workbench.action.chat.open", { query: "/qdk-learning Let's start the Quantum Katas.", isPartialQuery: false, @@ -75,11 +78,72 @@ export function registerLearningCommands( return; } + // If the activity lives in a non-active course, switch first so the + // service's active course matches before navigating. + if ( + service.initialized && + location.courseId !== service.getActiveCourseId() + ) { + await service.switchCourse(location.courseId, "tree"); + } + await service.goTo(location, "tree"); + + // For python-notebook exercise activities, open the notebook + // directly instead of showing the lesson panel. + if ( + service.getActiveCourseInfo().kind === "python-notebook" && + node.kind === "activity" && + node.activity.type === "exercise" + ) { + const notebookUri = service.getCurrentCodeFileUri(); + if (notebookUri) { + await vscode.commands.executeCommand( + "vscode.openWith", + notebookUri, + "jupyter-notebook", + { viewColumn: vscode.ViewColumn.Active, preview: false }, + ); + return; + } + } + + await panelManager.show(); + }, + ), + + // Multi-course commands + + vscode.commands.registerCommand( + "qsharp-vscode.learningSwitchCourse", + async (node?: LearningProgressNode) => { + const courseId = await resolveCourseId(service, node); + if (!courseId) { + return; + } + await service.switchCourse(courseId, "tree"); await panelManager.show(); }, ), + vscode.commands.registerCommand( + "qsharp-vscode.learningCourseInfo", + async (node?: LearningProgressNode) => { + const courseId = await resolveCourseId(service, node); + if (!courseId) { + return; + } + await showCourseInfo(service, courseId); + }, + ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningCheckEnvironment", + async (node?: LearningProgressNode) => { + await runEnvironmentCheckCommand(service, node); + }, + ), + vscode.commands.registerCommand( "qsharp-vscode.learningAskInChat", async (node: LearningProgressNode) => { @@ -101,11 +165,46 @@ export function registerLearningCommands( }); }, ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningNotebookHint", + async (arg?: number | { cell: vscode.NotebookCell }) => { + if (!service.initialized) { + return; + } + + const courseInfo = service.getActiveCourseInfo(); + if (courseInfo.kind !== "python-notebook") { + return; + } + + // Resolve 1-based cell number from the argument: + // - number: passed directly from the cell status bar item + // - { cell }: passed by VS Code when invoked from notebook/cell/title + let cellNumber: number | undefined; + if (typeof arg === "number") { + cellNumber = arg; + } else if (arg && "cell" in arg) { + cellNumber = arg.cell.index + 1; + } + + // Navigate to the exercise so the service state matches. + if (cellNumber) { + await service.goToExerciseByCellIndex(cellNumber, "panel"); + } + + await vscode.commands.executeCommand("workbench.action.chat.open", { + query: `/qdk-learning Give me a hint`, + }); + }, + ), ); } function nodeToTitle(node: LearningProgressNode): string { switch (node.kind) { + case "course": + return node.descriptor.title; case "continue": return node.activityTitle; case "activity": @@ -119,6 +218,8 @@ function nodeToLocation( node: LearningProgressNode, ): ActivityLocation | undefined { switch (node.kind) { + case "course": + return undefined; case "continue": return node.location; case "activity": @@ -138,3 +239,130 @@ function nodeToLocation( } } } + +/** + * Resolve a target course id from a tree node, or prompt the user with a + * quick pick when invoked without one (e.g. from the command palette). + */ +async function resolveCourseId( + service: LearningService, + node?: LearningProgressNode, +): Promise { + if (node?.kind === "course") { + return node.descriptor.id; + } + if (!service.initialized) { + const ok = await service.tryInitialize({ createIfMissing: true }); + if (!ok) { + return undefined; + } + } + const courses = await service.getCourses(); + if (courses.length === 0) { + return undefined; + } + const activeId = service.getActiveCourseId(); + const picked = await vscode.window.showQuickPick( + courses.map((c) => ({ + label: c.title, + description: c.id === activeId ? "current" : undefined, + detail: c.shortDescription, + id: c.id, + })), + { placeHolder: "Select a course" }, + ); + return picked?.id; +} + +/** Show a course's README in a markdown preview, or a fallback message. */ +async function showCourseInfo( + service: LearningService, + courseId: string, +): Promise { + const courses = await service.getCourses(); + const descriptor = courses.find((c) => c.id === courseId); + if (!descriptor) { + return; + } + if (descriptor.readmePath) { + const uri = vscode.Uri.parse(descriptor.readmePath); + await vscode.commands.executeCommand("markdown.showPreview", uri); + return; + } + const detail = descriptor.shortDescription + ? `\n\n${descriptor.shortDescription}` + : ""; + await vscode.window.showInformationMessage(`${descriptor.title}${detail}`, { + modal: false, + }); +} + +/** + * Run environment diagnostics for a course and present a rich, readable + * report, offering the fixes the report surfaces (e.g. one-click + * environment setup, install extensions). + */ +async function runEnvironmentCheckCommand( + service: LearningService, + node?: LearningProgressNode, +): Promise { + if (!service.initialized) { + const ok = await service.tryInitialize({ createIfMissing: true }); + if (!ok) { + vscode.window.showWarningMessage("Open a learning workspace first."); + return; + } + } + // If invoked on a specific course node, diagnose that course. + const courseId = node?.kind === "course" ? node.descriptor.id : undefined; + if (courseId && courseId !== service.getActiveCourseId()) { + await service.switchCourse(courseId, "tree"); + } + + const report = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Running course diagnostics…", + }, + () => service.runEnvironmentCheck(), + ); + + const icon: Record = { + ok: "✓", + warn: "▲", + fail: "✗", + skip: "–", + }; + const statusBadge: Record = { + ok: "✓ OK", + warning: "▲ Warning", + error: "✗ Error", + }; + + const lines = report.checks.map((c) => { + const head = `${icon[c.status] ?? "•"} ${c.label}`; + const detail = c.detail ? `\n ${c.detail}` : ""; + const hint = c.hint ? `\n → ${c.hint}` : ""; + return `${head}${detail}${hint}`; + }); + + const body = [ + `${statusBadge[report.overallStatus] ?? report.overallStatus} · ${report.summary}`, + "", + ...lines, + ].join("\n"); + + const actions = report.fixes.map((r) => r.label); + const choice = await vscode.window.showInformationMessage( + body, + { modal: true }, + ...actions, + ); + if (!choice) { + return; + } + const fix = report.fixes.find((r) => r.label === choice); + if (fix) { + await service.applyEnvironmentCheckFix(fix); + } +} diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts index 6c782a71358..b7048ce1e3c 100644 --- a/source/vscode/src/learning/constants.ts +++ b/source/vscode/src/learning/constants.ts @@ -10,6 +10,12 @@ export const LEARNING_WORKSPACE_RELATIVE_PATH = `./${LEARNING_WORKSPACE_FOLDER}` /** Well-known file that marks a workspace folder as a katas workspace. */ export const LEARNING_FILE = "qdk-learning.json"; +/** Subfolder (under the learning folder) that holds drop-in courses. */ +export const LEARNING_COURSES_SUBDIR = "courses"; + +/** Filename describing a drop-in course. */ +export const COURSE_MANIFEST_FILE = "course.json"; + /** Context key set when a learning workspace is detected. */ export const LEARNING_WORKSPACE_DETECTED_CONTEXT = "qsharp-vscode.learningWorkspaceDetected"; @@ -17,5 +23,8 @@ export const LEARNING_WORKSPACE_DETECTED_CONTEXT = /** Course ID for the built-in Quantum Katas. */ export const KATAS_COURSE_ID = "katas"; +/** Per-course virtual environment folder (under the course working copy). */ +export const LEARNING_VENV_DIR = ".venv"; + /** Tree view ID for the learning progress panel. */ export const LEARNING_TREE_VIEW_ID = "qsharp-vscode.learningTree"; diff --git a/source/vscode/src/learning/courseProvider.ts b/source/vscode/src/learning/courseProvider.ts new file mode 100644 index 00000000000..e80474c0534 --- /dev/null +++ b/source/vscode/src/learning/courseProvider.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { loadKatasCourse } from "./catalog.js"; +import { KATAS_COURSE_ID } from "./constants.js"; +import type { CatalogCourse, CourseDescriptor } from "./types.js"; + +/** + * A source of learning courses. Implementations know how to enumerate the + * courses they provide and how to fully load a course by id. + * + * Loading is intentionally split from enumeration so the UI can list + * available courses cheaply without materializing every course. + */ +export interface CourseProvider { + /** Stable identifier for this provider (for diagnostics/telemetry). */ + readonly id: string; + /** Enumerate the descriptors for all courses this provider offers. */ + listCourses(): Promise; + /** Fully load a course by id. Returns `undefined` if not provided here. */ + loadCourse(id: string): Promise; +} + +/** + * Aggregates multiple {@link CourseProvider}s into a single catalog of + * courses. The registry is the single entry point the service uses to + * discover and load courses regardless of where they come from. + */ +export class CourseRegistry { + constructor(private readonly providers: CourseProvider[]) {} + + /** Enumerate descriptors across all providers, in provider order. */ + async listCourses(): Promise { + const all: CourseDescriptor[] = []; + const seen = new Set(); + for (const provider of this.providers) { + let descriptors: CourseDescriptor[]; + try { + descriptors = await provider.listCourses(); + } catch { + // A misbehaving provider should not break the whole catalog. + continue; + } + for (const descriptor of descriptors) { + if (seen.has(descriptor.id)) { + continue; + } + seen.add(descriptor.id); + all.push(descriptor); + } + } + return all; + } + + /** Look up a single descriptor by id, or `undefined` if not found. */ + async getDescriptor(id: string): Promise { + const all = await this.listCourses(); + return all.find((d) => d.id === id); + } + + /** + * Fully load a course by id. Tries each provider in order and returns + * the first match. Throws if no provider can load the course. + */ + async loadCourse(id: string): Promise { + for (const provider of this.providers) { + const course = await provider.loadCourse(id); + if (course) { + return course; + } + } + throw new Error(`No provider could load course "${id}".`); + } +} + +/** Provider for the built-in Quantum Katas course. */ +export class KatasProvider implements CourseProvider { + readonly id = "katas-provider"; + + async listCourses(): Promise { + return [ + { + id: KATAS_COURSE_ID, + title: "Quantum Katas", + shortDescription: + "Hands-on quantum computing tutorials and exercises in Q#.", + kind: "qsharp", + }, + ]; + } + + async loadCourse(id: string): Promise { + if (id !== KATAS_COURSE_ID) { + return undefined; + } + return await loadKatasCourse(); + } +} diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts new file mode 100644 index 00000000000..b9e201ab8f8 --- /dev/null +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -0,0 +1,439 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import { + COURSE_MANIFEST_FILE, + LEARNING_COURSES_SUBDIR, + LEARNING_WORKSPACE_FOLDER, +} from "./constants.js"; +import type { CourseProvider } from "./courseProvider.js"; +import type { + CatalogActivity, + CatalogCourse, + CatalogExercise, + CatalogLesson, + CatalogUnit, + CourseDescriptor, + CourseEnvironment, + NotebookExerciseInfo, +} from "./types.js"; + +/** + * On-disk shape of a `course.json` manifest. Author-controlled, so every + * field is validated before use. + */ +interface CourseManifest { + schemaVersion?: number; + id?: unknown; + title?: unknown; + shortDescription?: unknown; + readme?: unknown; + units?: unknown; + environment?: unknown; +} + +interface ManifestUnit { + id: string; + title: string; + dir: string; +} + +/** A resolved course folder containing a parsed manifest. */ +interface CourseLocation { + /** Folder that contains `course.json`. */ + dir: vscode.Uri; + manifest: CourseManifest; +} + +/** + * Loads "drop-in" courses authored as folders on disk. A course is a + * folder containing a `course.json` manifest plus per-unit subfolders. + * Each unit is a Python notebook (`*.ipynb`) with an `intro.md` for the + * lesson panel and optional exercise metadata in `_exercises.json`. + * + * Course folders are discovered under `qdk-learning/courses/*` in the + * workspace. Malformed courses are skipped with a warning rather than + * failing the whole load. + */ +export class DropInCourseProvider implements CourseProvider { + readonly id = "drop-in-provider"; + + constructor(private readonly workspaceRoot: vscode.Uri) {} + + async listCourses(): Promise { + const locations = await this.discover(); + const seen = new Set(); + const descriptors: CourseDescriptor[] = []; + for (const loc of locations) { + const descriptor = await this.toDescriptor(loc); + if (!descriptor || seen.has(descriptor.id)) { + if (descriptor && seen.has(descriptor.id)) { + log.warn( + `Duplicate drop-in course id "${descriptor.id}" ignored at ${loc.dir.toString()}`, + ); + } + continue; + } + seen.add(descriptor.id); + descriptors.push(descriptor); + } + return descriptors; + } + + async loadCourse(id: string): Promise { + const locations = await this.discover(); + for (const loc of locations) { + if (manifestString(loc.manifest.id) === id) { + return this.parseCourse(loc); + } + } + return undefined; + } + + // ─── Discovery ─── + + /** Enumerate candidate course folders and parse their manifests. */ + private async discover(): Promise { + const dirs: vscode.Uri[] = []; + + // The well-known in-workspace courses folder. + const coursesRoot = vscode.Uri.joinPath( + this.workspaceRoot, + LEARNING_WORKSPACE_FOLDER, + LEARNING_COURSES_SUBDIR, + ); + for (const child of await readDirSafe(coursesRoot)) { + if (child.type === vscode.FileType.Directory) { + dirs.push(vscode.Uri.joinPath(coursesRoot, child.name)); + } + } + + const locations: CourseLocation[] = []; + for (const dir of dirs) { + const manifest = await this.readManifest(dir); + if (manifest) { + locations.push({ dir, manifest }); + } + } + return locations; + } + + /** Read and JSON-parse a course manifest, or `undefined` if absent/invalid. */ + private async readManifest( + dir: vscode.Uri, + ): Promise { + const manifestUri = vscode.Uri.joinPath(dir, COURSE_MANIFEST_FILE); + const text = await tryReadText(manifestUri); + if (text === undefined) { + return undefined; + } + try { + const parsed = JSON.parse(text) as CourseManifest; + if ( + manifestString(parsed.id) === undefined || + manifestString(parsed.title) === undefined + ) { + log.warn( + `Ignoring drop-in course at ${dir.toString()}: "id" and "title" are required.`, + ); + return undefined; + } + return parsed; + } catch (e) { + log.warn(`Failed to parse ${manifestUri.toString()}: ${String(e)}`); + return undefined; + } + } + + // ─── Parsing ─── + + private async toDescriptor( + loc: CourseLocation, + ): Promise { + const id = manifestString(loc.manifest.id); + const title = manifestString(loc.manifest.title); + if (id === undefined || title === undefined) { + return undefined; + } + const descriptor: CourseDescriptor = { + id, + title, + kind: "python-notebook", + shortDescription: manifestString(loc.manifest.shortDescription), + environment: manifestEnvironment(loc.manifest.environment), + }; + const readme = manifestString(loc.manifest.readme); + if (readme) { + const readmeUri = vscode.Uri.joinPath(loc.dir, readme); + if (await uriExists(readmeUri)) { + descriptor.readmePath = readmeUri.toString(); + } + } + return descriptor; + } + + private async parseCourse( + loc: CourseLocation, + ): Promise { + const id = manifestString(loc.manifest.id); + const title = manifestString(loc.manifest.title); + if (id === undefined || title === undefined) { + return undefined; + } + + const units: CatalogUnit[] = []; + for (const manifestUnit of manifestUnits(loc.manifest.units, loc.dir)) { + const unitDir = vscode.Uri.joinPath(loc.dir, manifestUnit.dir); + if (!(await uriExists(unitDir))) { + log.warn( + `Skipping unit "${manifestUnit.id}" in course "${id}": dir not found (${manifestUnit.dir}).`, + ); + continue; + } + const { activities, notebookExercises, notebookRel } = + await this.parseNotebookUnit(unitDir, manifestUnit); + if (activities.length === 0) { + log.warn( + `Unit "${manifestUnit.id}" in course "${id}" has no activities.`, + ); + } + units.push({ + id: manifestUnit.id, + title: manifestUnit.title, + activities, + notebookExercises, + notebookRel, + }); + } + + return { + id, + title, + kind: "python-notebook", + units, + sourceDir: loc.dir.toString(), + environment: manifestEnvironment(loc.manifest.environment), + }; + } + + /** + * Parse a `python-notebook` unit. Each unit produces a single text-lesson + * activity from `intro.md` in the unit dir. The notebook itself is + * opened by the user through the panel's "Open Notebook" action; the + * extension does not parse or execute cells. + * + * Exercise metadata (hints, solutions) is loaded from `_exercises.json` + * if present and attached to the returned unit for use by chat LM tools. + */ + private async parseNotebookUnit( + unitDir: vscode.Uri, + unit: ManifestUnit, + ): Promise<{ + activities: CatalogActivity[]; + notebookExercises?: NotebookExerciseInfo[]; + notebookRel?: string; + }> { + // Find the source notebook file in the unit dir. Materialized working + // copies (`*.workbook.ipynb`) sit beside the source and must be ignored + // here so they are never mistaken for the authored source notebook. + const entries = await readDirSafe(unitDir); + const notebookEntry = entries + .filter( + (e) => + e.type === vscode.FileType.File && + e.name.toLowerCase().endsWith(".ipynb") && + !e.name.toLowerCase().endsWith(".workbook.ipynb"), + ) + .sort((a, b) => a.name.localeCompare(b.name))[0]; + if (!notebookEntry) { + log.warn( + `Unit "${unit.id}" has no .ipynb notebook in ${unitDir.fsPath}.`, + ); + return { activities: [] }; + } + + const notebookRel = `${unit.dir}/${notebookEntry.name}`; + + // Read intro.md for the lesson panel content. + const introContent = + (await tryReadText(vscode.Uri.joinPath(unitDir, "intro.md"))) ?? ""; + + const activities: CatalogActivity[] = []; + if (introContent.length > 0) { + activities.push({ + type: "lesson", + id: "intro", + title: firstHeading(introContent) ?? humanize(unit.id), + content: introContent, + } satisfies CatalogLesson); + } else { + // Even without intro.md, emit a minimal lesson so navigation works. + activities.push({ + type: "lesson", + id: "intro", + title: unit.title, + content: `Open the notebook to begin this unit.`, + } satisfies CatalogLesson); + } + + // Load exercise metadata from _exercises.json (optional). + const exercisesJson = await tryReadText( + vscode.Uri.joinPath(unitDir, "_exercises.json"), + ); + let notebookExercises: NotebookExerciseInfo[] | undefined; + if (exercisesJson) { + try { + const parsed = JSON.parse(exercisesJson) as { + exercises?: unknown; + }; + if (Array.isArray(parsed.exercises)) { + notebookExercises = parsed.exercises.filter( + (e): e is NotebookExerciseInfo => + !!e && + typeof e === "object" && + typeof (e as NotebookExerciseInfo).id === "string", + ); + } + } catch (e) { + log.warn( + `Failed to parse _exercises.json in unit "${unit.id}": ${String(e)}`, + ); + } + } + + // Surface each notebook exercise as a catalog activity so it appears + // in the progress tree and can be navigated to. + if (notebookExercises) { + for (const ex of notebookExercises) { + activities.push({ + type: "exercise", + id: ex.id, + title: ex.title, + description: ex.description, + placeholderCode: "", + sourceIds: [], + hints: ex.hints, + solutionCodes: ex.solution ? [ex.solution] : [], + solutionExplanation: ex.solutionExplanation ?? "", + } satisfies CatalogExercise); + } + } + + return { activities, notebookExercises, notebookRel }; + } +} + +// ─── Manifest field validation ─── + +function manifestString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value + : undefined; +} + +function manifestEnvironment(value: unknown): CourseEnvironment | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const obj = value as { + requirements?: unknown; + python?: unknown; + importChecks?: unknown; + }; + const env: CourseEnvironment = {}; + + if ( + Array.isArray(obj.requirements) && + obj.requirements.every((r) => typeof r === "string") + ) { + env.requirements = obj.requirements as string[]; + } + + if (typeof obj.python === "string" && obj.python.length > 0) { + env.python = obj.python; + } + + if ( + Array.isArray(obj.importChecks) && + obj.importChecks.every((r) => typeof r === "string") + ) { + env.importChecks = obj.importChecks as string[]; + } + + return env; +} + +function manifestUnits(value: unknown, dir: vscode.Uri): ManifestUnit[] { + if (!Array.isArray(value)) { + log.warn(`Course at ${dir.toString()} has no "units" array.`); + return []; + } + const units: ManifestUnit[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") { + continue; + } + const id = manifestString((raw as { id?: unknown }).id); + const title = manifestString((raw as { title?: unknown }).title); + const unitDir = manifestString((raw as { dir?: unknown }).dir); + if (id === undefined || title === undefined || unitDir === undefined) { + log.warn( + `Ignoring malformed unit in course at ${dir.toString()} (requires id, title, dir).`, + ); + continue; + } + units.push({ id, title, dir: unitDir }); + } + return units; +} + +// ─── Filesystem helpers ─── + +async function readDirSafe( + uri: vscode.Uri, +): Promise<{ name: string; type: vscode.FileType }[]> { + try { + const entries = await vscode.workspace.fs.readDirectory(uri); + return entries.map(([name, type]) => ({ name, type })); + } catch { + return []; + } +} + +async function tryReadText(uri: vscode.Uri): Promise { + try { + const bytes = await vscode.workspace.fs.readFile(uri); + return new TextDecoder().decode(bytes); + } catch { + return undefined; + } +} + +async function uriExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} + +// ─── Text helpers ─── + +/** First markdown ATX heading (`# Title`) in the text, if any. */ +function firstHeading(markdown: string): string | undefined { + const match = markdown.match(/^#{1,6}\s+(.+?)\s*$/m); + return match ? match[1].trim() : undefined; +} + +/** Turn a file/dir slug into a human-readable title. */ +function humanize(slug: string): string { + return slug + .replace(/^\d+[-_.\s]*/, "") + .split(/[-_\s]+/) + .filter((w) => w.length > 0) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index 4c8cd7b2ea7..b8ec659dbb6 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -8,6 +8,7 @@ import { } from "./codeLens.js"; import { registerLearningCommands } from "./commands.js"; import { LessonPanelManager, registerLessonPanelSerializer } from "./panel.js"; +import { createNotebookCellStatusBarProvider } from "./notebookCellStatusBar.js"; import { registerLearningProgressView } from "./progressTreeView.js"; import { LearningService } from "./service.js"; import { registerLearningWelcomeView } from "./welcomeView.js"; @@ -30,6 +31,34 @@ export function initLearning( createLearningCodeLensProvider(), ), ); + context.subscriptions.push( + vscode.notebooks.registerNotebookCellStatusBarItemProvider( + "jupyter-notebook", + createNotebookCellStatusBarProvider(learningService), + ), + ); + context.subscriptions.push( + vscode.workspace.onDidChangeNotebookDocument((e) => { + // When a cell finishes executing (executionSummary changes), check + // if it corresponds to an exercise in the active python-notebook + // course and update focus. If execution succeeded, mark complete. + if ( + !learningService.initialized || + learningService.getActiveCourseInfo().kind !== "python-notebook" + ) { + return; + } + for (const change of e.cellChanges) { + if (change.executionSummary !== undefined) { + const cellIndex = change.cell.index + 1; + void learningService.goToExerciseByCellIndex(cellIndex, "panel"); + if (change.executionSummary.success) { + void learningService.markExerciseCompleteByCellIndex(cellIndex); + } + } + } + }), + ); registerLearningProgressView(context, learningService); registerLearningWelcomeView(context, learningService); registerLearningCommands(context, learningService, panelManager); @@ -38,7 +67,10 @@ export function initLearning( } export type { + CourseDescriptor, + CourseKind, CurrentActivity, + EnvironmentCheckReport, HintContext, OverallProgress, RunResult, diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts new file mode 100644 index 00000000000..1f7a0587730 --- /dev/null +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import type { LearningService } from "./service.js"; + +/** + * Pattern that identifies exercise/verification cells in python-notebook + * courses. These cells import check functions from the per-unit `_unit` + * module (e.g. `from _unit import check_value`). + */ +const exerciseCellPattern = /from\s+_unit\s+import\s+check/; + +/** + * Registers a {@link vscode.NotebookCellStatusBarItemProvider} that adds a + * "Ask for a Hint" button to exercise code cells in python-notebook courses. + */ +export function createNotebookCellStatusBarProvider( + service: LearningService, +): vscode.NotebookCellStatusBarItemProvider { + return { + provideCellStatusBarItems( + cell: vscode.NotebookCell, + ): vscode.NotebookCellStatusBarItem[] { + if (!service.initialized) { + return []; + } + + const courseInfo = service.getActiveCourseInfo(); + if (courseInfo.kind !== "python-notebook") { + return []; + } + + // Only annotate code cells whose text contains a check import. + if (cell.kind !== vscode.NotebookCellKind.Code) { + return []; + } + + const text = cell.document.getText(); + if (!exerciseCellPattern.test(text)) { + return []; + } + + // Use 1-based cell index as a definitive reference. + const cellNumber = cell.index + 1; + + const item = new vscode.NotebookCellStatusBarItem( + "$(comment-discussion-sparkle) Ask for a Hint", + vscode.NotebookCellStatusBarAlignment.Right, + ); + item.command = { + title: "Ask for a Hint", + command: "qsharp-vscode.learningNotebookHint", + arguments: [cellNumber], + }; + item.tooltip = "Open Copilot Chat for a hint on this exercise"; + return [item]; + }, + }; +} diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index 0977b169b92..056909166f8 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -47,6 +47,14 @@ export class LessonPanelManager { private readonly service: LearningService, ) {} + /** True when the active course is a python-notebook course. */ + private get isPythonNotebook(): boolean { + return ( + this.service.initialized && + this.service.getActiveCourseInfo().kind === "python-notebook" + ); + } + /** * Show or create the Lesson panel. */ @@ -68,16 +76,7 @@ export class LessonPanelManager { "qsharp-lesson", "Lesson", { viewColumn: vscode.ViewColumn.One, preserveFocus: false }, - { - enableScripts: true, - enableFindWidget: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, "out"), - vscode.Uri.joinPath(this.extensionUri, "resources"), - this.service.learningContentRoot, - ], - }, + this.getWebviewOptions(), ); this.panel.iconPath = { @@ -113,6 +112,10 @@ export class LessonPanelManager { this.panel = panel; + // Restored panels predate any webview-option changes, so re-apply the + // current options (e.g. allowlisted command URIs) before re-rendering. + this.panel.webview.options = this.getWebviewOptions(); + // Re-set HTML — webview resource URIs change across sessions. this.panel.webview.html = this.getWebviewContent(this.panel.webview); @@ -183,7 +186,10 @@ export class LessonPanelManager { if (!this.service.initialized) { return; } - this.sendMessage({ command: "state", state: this.service.getState() }); + this.sendMessage({ + command: "state", + state: this.service.getStateForPanel(), + }); } /** @@ -262,7 +268,7 @@ export class LessonPanelManager { command: "result", action, result, - state: this.service.getState(), + state: this.service.getStateForPanel(), } as Extract); } @@ -304,6 +310,29 @@ export class LessonPanelManager { return; } + if (msg.command === "switchCourse") { + await this.service.switchCourse(msg.courseId, "panel"); + this.sendState(); + return; + } + + if (msg.command === "courseInfo") { + await vscode.commands.executeCommand( + "qsharp-vscode.learningCourseInfo", + msg.courseId + ? { kind: "course", descriptor: { id: msg.courseId } } + : undefined, + ); + return; + } + + if (msg.command === "browseCourses") { + await vscode.commands.executeCommand( + "qsharp-vscode.learningSwitchCourse", + ); + return; + } + if (msg.command === "action") { await this.handleAction(msg.action); } @@ -317,12 +346,16 @@ export class LessonPanelManager { try { switch (action) { case "next": { - const result = await this.service.next("panel"); + const result = this.isPythonNotebook + ? await this.service.nextUnit("panel") + : await this.service.next("panel"); this.sendResult("next", result); break; } case "back": { - const result = await this.service.previous("panel"); + const result = this.isPythonNotebook + ? await this.service.previousUnit("panel") + : await this.service.previous("panel"); this.sendResult("back", result); break; } @@ -338,7 +371,7 @@ export class LessonPanelManager { } case "reset": { const confirmed = await vscode.window.showWarningMessage( - "Reset this exercise to the original placeholder code? Your current code will be lost.", + "Reset this unit to the original notebook? Your current work will be lost.", { modal: true }, "Reset", ); @@ -348,6 +381,10 @@ export class LessonPanelManager { this.sendState(); break; } + case "open-notebook": { + await this.openCourseNotebook(); + break; + } default: this.sendError(`Unknown action: ${action}`); } @@ -376,6 +413,54 @@ export class LessonPanelManager { ); } + /** + * Open the current unit's notebook in the Jupyter editor (column 2). + */ + private async openCourseNotebook(): Promise { + if (!this.service.initialized) { + return; + } + const notebookUri = this.service.getCurrentCodeFileUri(); + if (!notebookUri) { + return; + } + // Set a two-column layout: lesson panel left, notebook right. + await vscode.commands.executeCommand("vscode.setEditorLayout", { + orientation: 0, + groups: [{ size: 0.35 }, { size: 0.65 }], + }); + await vscode.commands.executeCommand( + "vscode.openWith", + notebookUri, + "jupyter-notebook", + { viewColumn: vscode.ViewColumn.Two, preview: false }, + ); + } + + /** + * Webview options for the lesson panel. + * + * `enableCommandUris` is restricted to an allowlist so author-supplied + * markdown (drop-in courses) can link to specific learning commands — e.g. + * a "Check my environment" button in a unit overview that runs the + * environment check — without granting the ability to invoke arbitrary VS + * Code commands. + */ + private getWebviewOptions(): vscode.WebviewPanelOptions & + vscode.WebviewOptions { + return { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + enableCommandUris: ["qsharp-vscode.learningCheckEnvironment"], + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, "out"), + vscode.Uri.joinPath(this.extensionUri, "resources"), + this.service.learningContentRoot, + ], + }; + } + private getWebviewContent(webview: vscode.Webview): string { const extensionUri = this.extensionUri; const cspSource = webview.cspSource; @@ -415,12 +500,12 @@ export class LessonPanelManager { private async checkSolutionAndSendResult( source?: TelemetrySource, ): Promise { - const { result, state } = await this.service.checkSolution(source); + const { result } = await this.service.checkSolution(source); this.sendMessage({ command: "result", action: "check", result, - state, + state: this.service.getStateForPanel(), }); return result.passed; } diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts index 08d12b27791..ce4c3dc92ff 100644 --- a/source/vscode/src/learning/progressTreeView.ts +++ b/source/vscode/src/learning/progressTreeView.ts @@ -4,6 +4,8 @@ import * as vscode from "vscode"; import type { ActivityLocation, + CourseDescriptor, + CourseKind, UnitProgress, OverallProgress, ActivityProgress, @@ -12,14 +14,15 @@ import type { LearningService } from "./service.js"; import { LEARNING_TREE_VIEW_ID } from "./constants.js"; /** - * Wire up the QDK Learning progress panel, a `TreeView` of Unit → Activity - * nodes with action buttons and progress indicators. + * Wire up the QDK Learning progress panel, a `TreeView` of + * Course → Unit → Activity nodes with action buttons and progress + * indicators. */ export function registerLearningProgressView( context: vscode.ExtensionContext, service: LearningService, ): void { - const treeDataProvider = new LearningProgressTreeProvider(); + const treeDataProvider = new LearningProgressTreeProvider(service); const treeView = vscode.window.createTreeView(LEARNING_TREE_VIEW_ID, { treeDataProvider, showCollapseAll: true, @@ -53,12 +56,48 @@ class LearningProgressTreeProvider implements vscode.TreeDataProvider u.total > 0 && u.completed === u.total, + ).length; + const item = new vscode.TreeItem( + descriptor.title, + isActive + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed, + ); + item.description = + totalUnits > 0 ? `${completedUnits}/${totalUnits}` : undefined; + item.iconPath = + descriptor.kind === "python-notebook" ? iconPython : iconCourse; + // The context value drives which package.json menu actions appear. + // Python courses get a distinct value so Python-only actions (the + // environment check) can be scoped to them. + item.contextValue = + descriptor.kind === "python-notebook" ? "coursePython" : "course"; + const envNote = + descriptor.kind === "python-notebook" + ? " \u00b7 Python environment" + : ""; + item.tooltip = `${descriptor.title}${envNote}${ + descriptor.shortDescription ? `\n${descriptor.shortDescription}` : "" + }`; + item.id = isActive + ? `course:${descriptor.id}:active` + : `course:${descriptor.id}`; + return item; + } + if (node.kind === "continue") { const item = new vscode.TreeItem( `Up next: ${node.activityTitle}`, @@ -128,48 +167,98 @@ class LearningProgressTreeProvider implements vscode.TreeDataProvider { + // Root: one node per available course. + if (!node) { + if (!this.service.initialized) { + return []; + } + let descriptors: CourseDescriptor[]; + try { + descriptors = await this.service.getCourses(); + } catch { + return []; + } + const activeCourseId = this.service.getActiveCourseId(); + const nodes: LearningProgressNode[] = []; + for (const descriptor of descriptors) { + const isActive = descriptor.id === activeCourseId; + let progress: OverallProgress | undefined; + if (isActive && this.snapshot) { + progress = this.snapshot; + } else { + try { + progress = await this.service.getCourseProgress(descriptor.id); + } catch { + progress = undefined; + } + } + if (!progress) { + continue; + } + nodes.push({ kind: "course", descriptor, progress, isActive }); + } + return nodes; } - if (!node) { + if (node.kind === "course") { + const { descriptor, progress, isActive } = node; const children: LearningProgressNode[] = []; - const { courseId, unitId, activityId } = snap.currentPosition; - const unit = snap.units.find((u) => u.id === unitId); - const activity = unit?.activities.find((a) => a.id === activityId); - if (unit && activity) { - children.push({ - kind: "continue", - location: { courseId, unitId: unit.id, activityId: activity.id }, - unitTitle: unit.title, - activityTitle: activity.title, - }); + // The "Up next" shortcut targets the active course's saved position. + if (isActive) { + const { courseId, unitId, activityId } = progress.currentPosition; + const unit = progress.units.find((u) => u.id === unitId); + const activity = unit?.activities.find((a) => a.id === activityId); + if (unit && activity) { + children.push({ + kind: "continue", + location: { courseId, unitId: unit.id, activityId: activity.id }, + unitTitle: unit.title, + activityTitle: activity.title, + }); + } } - for (const u of snap.units) { + const currentUnitId = isActive + ? progress.currentPosition.unitId + : undefined; + for (const u of progress.units) { children.push({ kind: "unit", - courseId, + courseId: descriptor.id, + courseKind: descriptor.kind, unit: u, - isCurrent: u.id === unitId, + isCurrent: u.id === currentUnitId, }); } - return children; } if (node.kind === "unit") { - const { unitId, activityId } = snap.currentPosition; - return node.unit.activities.map((activity) => ({ + const currentUnitId = this.snapshot?.currentPosition.unitId; + const currentActivityId = this.snapshot?.currentPosition.activityId; + const isActiveCourse = + this.service.initialized && + node.courseId === this.service.getActiveCourseId(); + // Hide the synthetic "intro" lesson for python-notebook courses — + // the panel already shows unit-level content. + const activities = + node.courseKind === "python-notebook" + ? node.unit.activities.filter((a) => a.id !== "intro") + : node.unit.activities; + return activities.map((activity) => ({ kind: "activity", courseId: node.courseId, unitId: node.unit.id, unitTitle: node.unit.title, activity, - isCurrent: node.unit.id === unitId && activity.id === activityId, + isCurrent: + isActiveCourse && + node.unit.id === currentUnitId && + activity.id === currentActivityId, })); } @@ -218,8 +307,15 @@ function buildTreeMessage( return `${completedUnits}/${units.length} units complete — ${encouragement}`; } -/** Discriminated union for the three kinds of tree nodes. */ +/** Discriminated union for the four kinds of tree nodes. */ export type LearningProgressNode = + | { + /** Top-level course node (expandable). */ + kind: "course"; + descriptor: CourseDescriptor; + progress: OverallProgress; + isActive: boolean; + } | { /** Pinned "Up next" shortcut at the top of the tree. */ kind: "continue"; @@ -231,6 +327,7 @@ export type LearningProgressNode = /** Unit node (expandable). */ kind: "unit"; courseId: string; + courseKind: CourseKind; unit: UnitProgress; isCurrent: boolean; } @@ -246,6 +343,11 @@ export type LearningProgressNode = // ─── Tree node icons ─── +const iconCourse = new vscode.ThemeIcon("mortar-board"); +const iconPython = new vscode.ThemeIcon( + "notebook", + new vscode.ThemeColor("charts.blue"), +); const iconContinue = new vscode.ThemeIcon( "sparkle", new vscode.ThemeColor("charts.blue"), diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts new file mode 100644 index 00000000000..7dd271dcec7 --- /dev/null +++ b/source/vscode/src/learning/python/environment.ts @@ -0,0 +1,491 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import { LEARNING_VENV_DIR } from "../constants.js"; + +/** + * Manages per-course Python environments for `python-notebook` courses. + * + * Every operating-system interaction (creating a venv, installing + * packages, registering a Jupyter kernel) is encapsulated behind a method + * here and routed through {@link runShell} so the underlying mechanism can + * later be swapped for the Python extension's API without touching callers. + * + * All file access uses `vscode.workspace.fs` and shell work uses the + * `vscode.tasks` API, keeping this module free of Node built-ins so the + * extension still bundles for VS Code for the Web (where these desktop-only + * operations are short-circuited). + */ +export class EnvironmentManager { + private readonly controllers = new Map(); + /** Cached result of probing for `uv` on the PATH. */ + private _uvAvailable: boolean | undefined; + + dispose(): void { + for (const controller of this.controllers.values()) { + controller.dispose(); + } + this.controllers.clear(); + } + + /** The course's virtual environment folder. */ + venvUri(courseRoot: vscode.Uri): vscode.Uri { + return vscode.Uri.joinPath(courseRoot, LEARNING_VENV_DIR); + } + + /** True on a host where environment management can run (desktop only). */ + get supported(): boolean { + return vscode.env.uiKind !== vscode.UIKind.Web; + } + + /** + * Locate a Python interpreter to bootstrap a venv. Prefers the Python + * extension's active interpreter, falling back to `python3`/`python`. + * Returns `undefined` when none can be determined. + */ + async ensureInterpreter(): Promise { + if (!this.supported) { + return undefined; + } + const fromExtension = await this.activeInterpreterPath(); + return fromExtension ?? "python3"; + } + + /** Whether the venv already exists on disk. */ + async venvExists(courseRoot: vscode.Uri): Promise { + return uriExists(this.venvUri(courseRoot)); + } + + /** + * Create the course venv if it does not yet exist. + * + * @param pythonSpec Optional Python version specifier from `course.json` + * (e.g. `">=3.11"`, `"3.12"`). When `uv` is available this is passed + * directly to `uv venv --python ` which lets `uv` discover or + * download a matching interpreter. When `uv` is unavailable, the spec + * is ignored and the system interpreter is used. + */ + async createVenv(courseRoot: vscode.Uri, pythonSpec?: string): Promise { + if (!this.supported || (await this.venvExists(courseRoot))) { + return; + } + const cwd = courseRoot; + const venvPath = this.venvUri(courseRoot).fsPath; + + // Prefer `uv` when it's available — it's faster and is the modern + // default tooling. Fall back to the standard library `venv` module. + if (await this.uvAvailable()) { + // With `uv`, pass the version spec (e.g. ">=3.11") or fall back to + // the system default. `uv` will discover or download a matching + // interpreter automatically. + const args = ["venv"]; + if (pythonSpec) { + args.push("--python", pythonSpec); + } + args.push(venvPath); + + const code = await this.runShell( + "Create course environment", + "uv", + args, + cwd, + ); + if (code === 0) { + return; + } + log.warn(`\`uv venv\` failed (exit ${code}); falling back to venv.`); + } + + // For the stdlib fallback we need an actual interpreter path. + const python = await this.ensureInterpreter(); + if (!python) { + throw new Error("No Python interpreter was found."); + } + + // Preflight: on some distros the `venv`/`ensurepip` modules are a + // separate OS package (e.g. Debian's `python3-venv`). Detect that here + // so we can surface an actionable message instead of an opaque failure. + const preflight = await this.runShell( + "Check Python venv support", + python, + ["-c", "import venv, ensurepip"], + cwd, + ); + if (preflight !== 0) { + throw new Error( + "This Python installation can't create virtual environments " + + "(the `venv`/`ensurepip` modules are missing). On Debian/Ubuntu " + + "install them with `sudo apt install python3-venv` (matching your " + + "Python version, e.g. `python3.12-venv`), then try again.", + ); + } + + const code = await this.runShell( + "Create course environment", + python, + ["-m", "venv", venvPath], + cwd, + ); + if (code !== 0) { + throw new Error( + `Creating the virtual environment failed (exit ${code}).`, + ); + } + } + + /** + * Sync the course environment using `uv sync`. This is the preferred + * method for courses that ship a `pyproject.toml`. It creates the `.venv` + * in the course's root and installs all declared dependencies + * in a single command. + * + * @param courseRoot The course's source folder (where `pyproject.toml` + * lives and where the `.venv` is created). + */ + async syncEnvironment(courseRoot: vscode.Uri): Promise { + if (!this.supported) { + return; + } + + if (!(await this.uvAvailable())) { + throw new Error( + "`uv` is required to set up this course's Python environment but " + + "was not found on your PATH. Install it from https://docs.astral.sh/uv/", + ); + } + + const code = await this.runShell( + "Sync course environment", + "uv", + ["sync", "--project", courseRoot.fsPath], + courseRoot, + ); + if (code !== 0) { + throw new Error( + `\`uv sync\` failed (exit ${code}). Check the terminal output for details.`, + ); + } + } + + /** + * Install the course's pinned requirements into its venv. Always installs + * `ipykernel` as well so the Jupyter extension can discover and run the + * venv as a notebook kernel without a globally-registered kernelspec. + */ + async installRequirements( + courseRoot: vscode.Uri, + requirements: string[], + ): Promise { + if (!this.supported) { + return; + } + const python = await this.venvPython(courseRoot); + if (!python) { + throw new Error("The course environment is missing its interpreter."); + } + // De-duplicate while preserving order; ipykernel is required for the + // venv to act as a Jupyter kernel. + const packages = [...new Set(["ipykernel", ...requirements])]; + const cwd = courseRoot; + + if (await this.uvAvailable()) { + const code = await this.runShell( + "Install course requirements", + "uv", + ["pip", "install", "--python", python, ...packages], + cwd, + ); + if (code === 0) { + return; + } + log.warn( + `\`uv pip install\` failed (exit ${code}); falling back to pip.`, + ); + } + + const code = await this.runShell( + "Install course requirements", + python, + ["-m", "pip", "install", "--disable-pip-version-check", ...packages], + cwd, + ); + if (code !== 0) { + throw new Error(`Installing requirements failed (exit ${code}).`); + } + } + + /** + * Select this course's venv as the kernel/interpreter for a notebook. + * + * Uses the **stable** `ms-python.python` `environments` API + * (`updateActiveEnvironmentPath`) to set the interpreter for the notebook + * resource — the mechanism the Jupyter extension honors when picking a + * kernel — and additionally nudges the picker with a core + * {@link vscode.NotebookController} affinity hint. + * + * We deliberately do NOT register a global kernelspec + * (`ipykernel install --user`): that pollutes the user's kernel list and + * competes with the Jupyter extension's own environment discovery. + * Instead {@link installRequirements} puts `ipykernel` in the venv so + * Jupyter can discover and run it directly. + */ + async selectKernelForNotebook( + notebook: vscode.NotebookDocument, + courseRoot: vscode.Uri, + courseId: string, + displayName: string, + ): Promise { + if (!this.supported) { + return; + } + + // Primary, stable path: point the Python extension at the venv + // interpreter for this notebook resource. + const python = await this.venvPython(courseRoot); + if (python) { + await this.setActiveInterpreter(notebook.uri, python); + } + + // Secondary nudge: a notebook controller affinity hint. This is a core + // VS Code API (not Python-specific) and is safe to keep as a fallback. + let controller = this.controllers.get(courseId); + if (!controller) { + controller = vscode.notebooks.createNotebookController( + `qdk-learning-${courseId}`, + "jupyter-notebook", + `QDK: ${displayName}`, + ); + controller.supportedLanguages = ["python"]; + controller.description = "QDK course environment"; + this.controllers.set(courseId, controller); + } + controller.updateNotebookAffinity( + notebook, + vscode.NotebookControllerAffinity.Preferred, + ); + } + + /** Path to the venv's Python interpreter, or `undefined` if not present. */ + async venvPython(courseRoot: vscode.Uri): Promise { + const venv = this.venvUri(courseRoot); + const candidates = [ + vscode.Uri.joinPath(venv, "bin", "python"), + vscode.Uri.joinPath(venv, "bin", "python3"), + vscode.Uri.joinPath(venv, "Scripts", "python.exe"), + ]; + for (const candidate of candidates) { + if (await uriExists(candidate)) { + return candidate.fsPath; + } + } + return undefined; + } + + /** + * Verify the given modules import in the course venv (e.g. `qdk`, + * `qsharp_widgets`). Returns `false` if the venv or interpreter is + * missing or the import fails. + */ + async checkImports( + courseRoot: vscode.Uri, + modules: string[], + ): Promise { + if (!this.supported || modules.length === 0) { + return false; + } + const python = await this.venvPython(courseRoot); + if (!python) { + return false; + } + const code = await this.runShell( + "Verify course packages", + python, + ["-c", `import ${modules.join(", ")}`], + courseRoot, + ); + return code === 0; + } + + /** + * Per-module import report for the course venv. Each entry is `true` when + * that module imports successfully. Missing venv/interpreter yields all + * `false`. Used by the environment check to pinpoint which package is + * missing. + */ + async importsReport( + courseRoot: vscode.Uri, + modules: string[], + ): Promise<{ module: string; ok: boolean }[]> { + if (!this.supported || modules.length === 0) { + return modules.map((module) => ({ module, ok: false })); + } + const python = await this.venvPython(courseRoot); + if (!python) { + return modules.map((module) => ({ module, ok: false })); + } + const results: { module: string; ok: boolean }[] = []; + for (const module of modules) { + const code = await this.runShell( + `Check import: ${module}`, + python, + ["-c", `import ${module}`], + courseRoot, + ); + results.push({ module, ok: code === 0 }); + } + return results; + } + + /** Whether `uv` is available on the PATH (public diagnostics accessor). */ + async hasUv(): Promise { + return this.uvAvailable(); + } + + /** + * Whether the given interpreter can create virtual environments (the + * `venv` and `ensurepip` modules are importable). On some Linux distros + * these are a separate OS package. Defaults to the bootstrap interpreter. + */ + async venvModuleSupported(python?: string): Promise { + if (!this.supported) { + return false; + } + const interpreter = python ?? (await this.ensureInterpreter()); + if (!interpreter) { + return false; + } + const code = await this.runShell("Check Python venv support", interpreter, [ + "-c", + "import venv, ensurepip", + ]); + return code === 0; + } + + // ─── Private: swappable OS interaction ─── + + /** + * The Python extension's stable `environments` API, or `undefined` when + * the extension is unavailable. Only the documented, non-proposed members + * are typed here. + */ + private async pythonEnvironmentsApi(): Promise< + | { + getActiveEnvironmentPath?: (resource?: vscode.Uri) => { + path?: string; + }; + updateActiveEnvironmentPath?: ( + environment: string, + resource?: vscode.Uri, + ) => Thenable; + } + | undefined + > { + const ext = vscode.extensions.getExtension("ms-python.python"); + if (!ext) { + return undefined; + } + try { + const api = (await ext.activate()) as { + environments?: { + getActiveEnvironmentPath?: (resource?: vscode.Uri) => { + path?: string; + }; + updateActiveEnvironmentPath?: ( + environment: string, + resource?: vscode.Uri, + ) => Thenable; + }; + }; + return api.environments; + } catch (e) { + log.warn(`Could not query the Python extension: ${String(e)}`); + return undefined; + } + } + + /** The Python extension's active interpreter path, if available. */ + private async activeInterpreterPath(): Promise { + const environments = await this.pythonEnvironmentsApi(); + return environments?.getActiveEnvironmentPath?.()?.path; + } + + /** + * Set the active interpreter for a resource via the stable Python + * extension API. The Jupyter extension uses this association to pick the + * kernel for the notebook. + */ + private async setActiveInterpreter( + resource: vscode.Uri, + pythonPath: string, + ): Promise { + const environments = await this.pythonEnvironmentsApi(); + if (!environments?.updateActiveEnvironmentPath) { + return; + } + try { + await environments.updateActiveEnvironmentPath(pythonPath, resource); + } catch (e) { + log.warn(`Could not set the active interpreter: ${String(e)}`); + } + } + + /** Whether `uv` is available on the PATH. Cached after the first probe. */ + private async uvAvailable(): Promise { + if (this._uvAvailable === undefined) { + const code = await this.runShell("Check for uv", "uv", ["--version"]); + this._uvAvailable = code === 0; + } + return this._uvAvailable; + } + + /** + * Run a shell command as a one-shot task and resolve with its exit code. + * Centralized so the execution mechanism stays swappable. + */ + private runShell( + name: string, + command: string, + args: string[], + cwd?: vscode.Uri, + ): Promise { + // Course commands pass the course root; course-independent probes + // (`uv --version`, `python -c "import venv"`) don't depend on the cwd, + // so they fall back to the workspace folder, which is guaranteed to exist. + const cwdPath = (cwd ?? vscode.workspace.workspaceFolders?.[0]?.uri) + ?.fsPath; + const task = new vscode.Task( + { type: "qdk-learning" }, + vscode.TaskScope.Workspace, + name, + "qdk-learning", + new vscode.ShellExecution(command, args, { cwd: cwdPath }), + ); + task.presentationOptions = { + reveal: vscode.TaskRevealKind.Silent, + focus: false, + clear: false, + }; + return new Promise((resolve) => { + const sub = vscode.tasks.onDidEndTaskProcess((e) => { + if (e.execution.task === task) { + sub.dispose(); + resolve(e.exitCode ?? -1); + } + }); + void vscode.tasks.executeTask(task); + }); + } +} + +// ─── Helpers ─── + +async function uriExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} diff --git a/source/vscode/src/learning/python/pythonRunner.ts b/source/vscode/src/learning/python/pythonRunner.ts new file mode 100644 index 00000000000..932259e6adf --- /dev/null +++ b/source/vscode/src/learning/python/pythonRunner.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import type { CatalogCourse } from "../types.js"; + +/** + * Manages `python-notebook` course files. All Jupyter/notebook execution + * is handled by VS Code's native notebook UI — this class only handles + * materialization (copying course source to a working copy) and extension + * readiness checks. + */ +export class PythonCourseRunner { + /** + * Soft-check that the Python and Jupyter extensions are available. On + * VS Code for the Web (where they can't run) returns a desktop-only + * message. Returns `undefined` when everything required is present. + */ + async ensureExtensions(): Promise { + if (vscode.env.uiKind === vscode.UIKind.Web) { + return ( + "Python notebook courses require the desktop version of VS Code " + + "with the Python and Jupyter extensions." + ); + } + const missing: { id: string; name: string }[] = []; + if (!vscode.extensions.getExtension("ms-python.python")) { + missing.push({ id: "ms-python.python", name: "Python" }); + } + if (!vscode.extensions.getExtension("ms-toolsai.jupyter")) { + missing.push({ id: "ms-toolsai.jupyter", name: "Jupyter" }); + } + if (missing.length === 0) { + return undefined; + } + return `This course needs the ${missing + .map((m) => m.name) + .join(" and ")} extension${missing.length > 1 ? "s" : ""}.`; + } + + /** + * Prompt the user to install any missing required extensions. Safe to + * call when nothing is missing (it no-ops). + */ + async promptInstallExtensions(): Promise { + if (vscode.env.uiKind === vscode.UIKind.Web) { + return; + } + const required: { id: string; name: string }[] = [ + { id: "ms-python.python", name: "Python" }, + { id: "ms-toolsai.jupyter", name: "Jupyter" }, + ].filter((e) => !vscode.extensions.getExtension(e.id)); + if (required.length === 0) { + return; + } + const choice = await vscode.window.showInformationMessage( + `This course needs the ${required + .map((r) => r.name) + .join(" and ")} extension${required.length > 1 ? "s" : ""}.`, + "Install", + ); + if (choice !== "Install") { + return; + } + for (const ext of required) { + await vscode.commands.executeCommand( + "workbench.extensions.installExtension", + ext.id, + ); + } + } + + /** + * Working-copy URI of a unit's notebook: a `*.workbook.ipynb` file that + * sits beside the authored source notebook in the same unit folder. + * + * Keeping the working copy as a sibling means the learner's notebook + * resolves the same relative imports (`_course_lib.py`, `_unit.py`, etc.) as the + * source. + */ + workbookFileUri(course: CatalogCourse, notebookRel: string): vscode.Uri { + if (!course.sourceDir) { + throw new Error(`Course "${course.id}" has no source folder.`); + } + const sourceRoot = vscode.Uri.parse(course.sourceDir); + return vscode.Uri.joinPath(sourceRoot, toWorkbookRel(notebookRel)); + } + + /** + * Materialize the working copy for every unit in the course: copy each + * authored notebook to its `*.workbook.ipynb` sibling. Existing workbooks + * are never overwritten, preserving learner edits. + */ + async materializeCourse(course: CatalogCourse): Promise { + if (!course.sourceDir) { + throw new Error(`Course "${course.id}" has no source folder.`); + } + const sourceRoot = vscode.Uri.parse(course.sourceDir); + + for (const unit of course.units) { + if (!unit.notebookRel) { + continue; + } + await this.copyIfMissing( + vscode.Uri.joinPath(sourceRoot, unit.notebookRel), + vscode.Uri.joinPath(sourceRoot, toWorkbookRel(unit.notebookRel)), + ); + } + } + + /** + * Re-materialize a single unit: overwrite its `*.workbook.ipynb` + * with a fresh copy of the authored notebook. + */ + async rematerializeUnit( + course: CatalogCourse, + unitId: string, + ): Promise { + if (!course.sourceDir) { + throw new Error(`Course "${course.id}" has no source folder.`); + } + const unit = course.units.find((u) => u.id === unitId); + if (!unit?.notebookRel) { + throw new Error(`Unit "${unitId}" not found in course "${course.id}".`); + } + + const sourceRoot = vscode.Uri.parse(course.sourceDir); + const src = vscode.Uri.joinPath(sourceRoot, unit.notebookRel); + const dest = vscode.Uri.joinPath( + sourceRoot, + toWorkbookRel(unit.notebookRel), + ); + await ensureParentDir(dest); + await vscode.workspace.fs.copy(src, dest, { overwrite: true }); + } + + /** Copy a file only if the destination doesn't already exist. */ + private async copyIfMissing( + src: vscode.Uri, + dest: vscode.Uri, + ): Promise { + if (await uriExists(dest)) { + return; + } + try { + await ensureParentDir(dest); + await vscode.workspace.fs.copy(src, dest, { overwrite: false }); + } catch (e) { + log.warn(`Failed to copy ${src.fsPath} → ${dest.fsPath}: ${String(e)}`); + } + } +} + +// ─── Helpers ─── + +/** + * Map a source notebook's relative path to its working-copy sibling by + * swapping the `.ipynb` extension for `.workbook.ipynb` + * (e.g. `01-intro/intro.ipynb` → `01-intro/intro.workbook.ipynb`). + */ +function toWorkbookRel(notebookRel: string): string { + return notebookRel.replace(/\.ipynb$/i, ".workbook.ipynb"); +} + +async function uriExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} + +async function ensureParentDir(fileUri: vscode.Uri): Promise { + const parentUri = vscode.Uri.joinPath(fileUri, ".."); + try { + await vscode.workspace.fs.createDirectory(parentUri); + } catch { + // already exists + } +} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index d542038bb32..74b25ffbffd 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -6,8 +6,12 @@ import * as vscode from "vscode"; import { FullProgramConfig, getProgramForDocument } from "../programConfig.js"; import { ProgramRunStatus, runProgram } from "../run.js"; import { EventType, sendTelemetryEvent } from "../telemetry.js"; -import { loadKatasCourse } from "./catalog.js"; +import { createCourseRegistry } from "./catalog.js"; +import { CourseRegistry } from "./courseProvider.js"; +import { EnvironmentManager } from "./python/environment.js"; +import { PythonCourseRunner } from "./python/pythonRunner.js"; import { + KATAS_COURSE_ID, LEARNING_FILE, LEARNING_WORKSPACE_DETECTED_CONTEXT, LEARNING_WORKSPACE_FOLDER, @@ -22,7 +26,13 @@ import type { CatalogExercise, CatalogActivity, CatalogUnit, + CourseDescriptor, + CourseKind, CurrentActivity, + EnvironmentCheckFix, + EnvironmentCheckItem, + EnvironmentCheckReport, + EnvironmentStatus, ExerciseContent, HintContext, LearningState, @@ -38,6 +48,24 @@ import type { UnitProgress, UnitSummary, } from "./types.js"; +import type { EnvironmentCheckStatus } from "./types.js"; + +/** Build an {@link EnvironmentCheckItem}. */ +function check( + id: string, + label: string, + status: EnvironmentCheckStatus, + extras?: Pick, +): EnvironmentCheckItem { + return { + id, + label, + status, + detail: extras?.detail, + hint: extras?.hint, + fixes: extras?.fixes, + }; +} /** Returns the first open workspace folder URI, or `undefined`. */ export function resolveNewWorkspaceRoot(): vscode.Uri | undefined { @@ -90,8 +118,10 @@ interface LearningWorkspaceInfo { /** All state that exists only while a learning workspace is loaded. */ interface WorkspaceState extends LearningWorkspaceInfo { - /** Currently, only a single course is supported. */ - catalog: CatalogCourse; + /** Loaded courses, keyed by course id. May contain more than one. */ + courses: Map; + /** Registry used to enumerate and lazily load additional courses. */ + registry: CourseRegistry; progressData: ProgressFileData; } @@ -108,8 +138,12 @@ export class LearningService { private _lastSnapshot: OverallProgress | undefined; private _progressFileWatcher: vscode.FileSystemWatcher | undefined; + private _sentinelWatcher: vscode.FileSystemWatcher | undefined; private _writingProgress = false; private _initPromise: Promise | undefined; + private readonly _disposables: vscode.Disposable[] = []; + private _pythonRunner: PythonCourseRunner | undefined; + private _environment: EnvironmentManager | undefined; constructor(private readonly extensionUri: vscode.Uri) {} @@ -121,6 +155,40 @@ export class LearningService { return this.requireWorkspace().learningContentRoot; } + /** The workspace folder that owns the learning content. */ + get workspaceFolder(): vscode.Uri { + return this.requireWorkspace().workspaceRoot; + } + + /** Lazily-created runner for `python-notebook` courses. */ + private get pythonRunner(): PythonCourseRunner { + if (!this._pythonRunner) { + this._pythonRunner = new PythonCourseRunner(); + } + return this._pythonRunner; + } + + /** Lazily-created per-course Python environment manager. */ + private get environment(): EnvironmentManager { + if (!this._environment) { + this._environment = new EnvironmentManager(); + } + return this._environment; + } + + /** + * Re-scan available courses (e.g. after a new drop-in course is added). + * Drop-in courses are enumerated lazily by the registry, so this just + * refreshes the UI to pick up newly-added folders. + */ + async reloadCourses(): Promise { + if (!this.workspace) { + return; + } + this.emitProgress(); + this._onDidChangeState.fire(this.getState()); + } + /** * Try to initialize the service. Returns `true` when ready, `false` * when no learning workspace could be found (or created). @@ -165,6 +233,11 @@ export class LearningService { this._onDidChangeState.dispose(); this._onDidChangeProgress.dispose(); this._progressFileWatcher?.dispose(); + this.stopSentinelWatcher(); + this._environment?.dispose(); + for (const d of this._disposables) { + d.dispose(); + } } /** Force a fresh progress reload from disk. */ @@ -197,12 +270,50 @@ export class LearningService { * The payload sent to the webview. */ getState(): LearningState { return { + course: this.getActiveCourseInfo(), position: this.getCurrentActivity(), actions: this.getAvailableActions(), progress: this.getProgress(), }; } + /** + * State snapshot tailored for the lesson webview panel. + * + * For python-notebook courses the panel always shows the unit-level + * summary (intro lesson) rather than drilling into a specific exercise. + * Other course kinds fall through to {@link getState}. + */ + getStateForPanel(): LearningState { + if (this.activeCourse.kind !== "python-notebook") { + return this.getState(); + } + + const pos = this.position; + const unit = this.findUnit(pos.unitId); + const intro = unit.activities.find((a) => a.id === "intro")!; + + const introLocation: ActivityLocation = { + courseId: pos.courseId, + unitId: pos.unitId, + activityId: intro.id, + }; + + const position: CurrentActivity = { + location: introLocation, + unitTitle: unit.title, + activityTitle: unit.title, + content: this.resolveActivityContent(introLocation, unit, intro), + }; + + return { + course: this.getActiveCourseInfo(), + position, + actions: this.getAvailableActionsForPanel(unit), + progress: this.getProgress(), + }; + } + async next(source: TelemetrySource): Promise { const ws = this.requireWorkspace(); const currentPos = ws.progressData.position; @@ -244,12 +355,81 @@ export class LearningService { return { moved: true }; } + /** + * Navigate to the intro of the next unit. Used by the panel for + * python-notebook courses where navigation is unit-scoped. + */ + async nextUnit(source: TelemetrySource): Promise { + const ws = this.requireWorkspace(); + const course = this.activeCourse; + const currentUnitId = ws.progressData.position.unitId; + const idx = course.units.findIndex((u) => u.id === currentUnitId); + if (idx < 0 || idx >= course.units.length - 1) { + return { moved: false }; + } + const nextU = course.units[idx + 1]; + const firstActivity = nextU.activities[0]; + if (!firstActivity) { + return { moved: false }; + } + + // Auto-mark the intro lesson of the current unit complete. + const introLocation: ActivityLocation = { + courseId: course.id, + unitId: currentUnitId, + activityId: "intro", + }; + if (!this.isComplete(introLocation)) { + this.markComplete(introLocation); + } + + ws.progressData.position = { + courseId: course.id, + unitId: nextU.id, + activityId: firstActivity.id, + }; + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + this.sendActivityActionTelemetry("navigate", source); + return { moved: true }; + } + + /** + * Navigate to the intro of the previous unit. Used by the panel for + * python-notebook courses where navigation is unit-scoped. + */ + async previousUnit(source: TelemetrySource): Promise { + const ws = this.requireWorkspace(); + const course = this.activeCourse; + const currentUnitId = ws.progressData.position.unitId; + const idx = course.units.findIndex((u) => u.id === currentUnitId); + if (idx <= 0) { + return { moved: false }; + } + const prevU = course.units[idx - 1]; + const firstActivity = prevU.activities[0]; + if (!firstActivity) { + return { moved: false }; + } + + ws.progressData.position = { + courseId: course.id, + unitId: prevU.id, + activityId: firstActivity.id, + }; + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + this.sendActivityActionTelemetry("navigate", source); + return { moved: true }; + } + async goTo( location: { unitId: string; activityId?: string }, source?: TelemetrySource, ): Promise { const ws = this.requireWorkspace(); - const unit = ws.catalog.units.find((u) => u.id === location.unitId); + const course = this.activeCourse; + const unit = course.units.find((u) => u.id === location.unitId); if (!unit || unit.activities.length === 0) { throw new Error(`Position not found: ${location.unitId}`); } @@ -262,7 +442,7 @@ export class LearningService { ); } ws.progressData.position = { - courseId: ws.catalog.id, + courseId: course.id, unitId: location.unitId, activityId: activity.id, }; @@ -275,17 +455,471 @@ export class LearningService { return state; } - listUnits(): UnitSummary[] { + /** + * Navigate to the exercise activity whose `cellIndex` matches the given + * 1-based cell number. Returns `true` if the position was updated. + * Only meaningful for python-notebook courses. + * + * Updates the position silently — does **not** fire the state-change + * event, so the lesson panel won't pop up or rearrange the editor layout. + */ + async goToExerciseByCellIndex( + cellIndex: number, + source?: TelemetrySource, + ): Promise { + if (this.activeCourse.kind !== "python-notebook") { + return false; + } + const unit = this.findUnit(this.position.unitId); + const exercise = unit.notebookExercises?.find( + (e) => e.cellIndex === cellIndex, + ); + if (!exercise) { + return false; + } + // Only move if we're not already on this exercise. + if (this.position.activityId === exercise.id) { + return true; + } + const ws = this.requireWorkspace(); + ws.progressData.position = { + courseId: this.activeCourse.id, + unitId: unit.id, + activityId: exercise.id, + }; + await this.saveProgress(); + if (source) { + this.sendActivityActionTelemetry("navigate", source); + } + return true; + } + + /** + * Mark the exercise activity at the given 1-based cell index as complete. + * Returns `true` if the exercise was found and marked (or already complete). + * Fires the state-change event so the treeview updates. + */ + async markExerciseCompleteByCellIndex(cellIndex: number): Promise { + if (this.activeCourse.kind !== "python-notebook") { + return false; + } + const unit = this.findUnit(this.position.unitId); + const exercise = unit.notebookExercises?.find( + (e) => e.cellIndex === cellIndex, + ); + if (!exercise) { + return false; + } + const location: ActivityLocation = { + courseId: this.activeCourse.id, + unitId: unit.id, + activityId: exercise.id, + }; + if (this.isComplete(location)) { + return true; + } + this.markComplete(location); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + return true; + } + + /** Enumerate all available courses (loaded or not). */ + async getCourses(): Promise { + return this.requireWorkspace().registry.listCourses(); + } + + /** The id of the currently-active course. */ + getActiveCourseId(): string { + return this.requireWorkspace().progressData.position.courseId; + } + + /** Compact info about the active course for serialization to chat tools. */ + getActiveCourseInfo(): { id: string; title: string; kind: CourseKind } { + const course = this.activeCourse; + return { id: course.id, title: course.title, kind: course.kind }; + } + + /** + * Ensure a python-notebook course's per-course environment exists: + * create the venv and install pinned requirements. No-ops for Q# courses, + * on the Web, or when the venv already exists (unless `force` is set). + * + * When the course ships a `pyproject.toml`, the preferred path is + * `uv sync` which handles venv creation, Python version selection, and + * dependency installation in one shot. Courses without `pyproject.toml` + * fall back to the manual `createVenv` + `installRequirements` flow. + */ + async ensureEnvironment( + course: CatalogCourse, + options?: { force?: boolean }, + ): Promise { + if (course.kind !== "python-notebook") { + return; + } + const env = this.environment; + if (!env.supported) { + return; + } + if (!course.sourceDir) { + return; + } + const courseRoot = vscode.Uri.parse(course.sourceDir); + if (!options?.force && (await env.venvExists(courseRoot))) { + return; + } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Setting up the environment for "${course.title}"…`, + }, + async () => { + const hasPyproject = await this.uriExists( + vscode.Uri.joinPath(courseRoot, "pyproject.toml"), + ); + if (hasPyproject) { + // Preferred: `uv sync` resolves and installs from pyproject.toml. + await env.syncEnvironment(courseRoot); + } else { + // Fallback: manual venv creation + pip install. + await env.createVenv(courseRoot, course.environment?.python); + await env.installRequirements( + courseRoot, + course.environment?.requirements ?? [], + ); + } + }, + ); + } + + /** Set up the environment for the currently-active course. */ + async setupActiveEnvironment(): Promise { + await this.ensureEnvironment(this.activeCourse, { force: true }); + } + + /** + * Apply a fix surfaced by {@link runEnvironmentCheck}. Centralizes the + * mapping from an {@link EnvironmentCheckFix.kind} to a concrete action so + * the command and chat tool can offer fixes without duplicating the logic. + */ + async applyEnvironmentCheckFix(fix: EnvironmentCheckFix): Promise { + switch (fix.kind) { + case "setup": + await this.setupActiveEnvironment(); + return; + case "install-extensions": + await this.pythonRunner.promptInstallExtensions(); + return; + case "select-kernel": + await vscode.commands.executeCommand("notebook.selectKernel"); + return; + case "docs": + return; + } + } + + /** + * Run environment diagnostics for the active course and return a rich, + * structured report: an ordered list of checks (each `ok`/`warn`/`fail`/ + * `skip` with detail, a fix hint, and fixes), an overall status, a + * one-line summary, and the aggregated fixes the UI can offer. + * + * Q# courses need no environment and pass trivially. + */ + async runEnvironmentCheck(): Promise { + const course = this.activeCourse; + + if (course.kind !== "python-notebook") { + const checks: EnvironmentCheckItem[] = [ + check("course-kind", "Course type", "ok", { + detail: "Q# course — runs on the built-in simulator.", + }), + check("environment", "Python environment", "skip", { + detail: "Not required for Q# courses.", + }), + ]; + return this.assembleReport(course, checks); + } + + const env = this.environment; + + // Hard stop: environment management can't run on the Web. + if (!env.supported) { + const checks: EnvironmentCheckItem[] = [ + check("host", "Desktop VS Code", "fail", { + detail: "Python courses require the desktop version of VS Code.", + hint: "Open this workspace in desktop VS Code to run Python courses.", + }), + ]; + return this.assembleReport(course, checks); + } + + // Resolve the course's working root (its source folder); the venv + // lives here, beside the authored notebooks. + if (!course.sourceDir) { + return this.assembleReport(course, [ + check("course-folder", "Course folder", "fail", { + detail: "This course has no source folder on disk.", + }), + ]); + } + const courseRoot = vscode.Uri.parse(course.sourceDir); + + const checks: EnvironmentCheckItem[] = []; + + // 1. Required extensions (Python + Jupyter). + const extMessage = await this.pythonRunner.ensureExtensions(); + checks.push( + check( + "extensions", + "Python & Jupyter extensions", + extMessage ? "fail" : "ok", + { + detail: extMessage ?? "Installed.", + hint: extMessage + ? "Install the Python and Jupyter extensions to run notebook courses." + : undefined, + fixes: extMessage + ? [{ label: "Install extensions", kind: "install-extensions" }] + : undefined, + }, + ), + ); + + // 2. Base Python interpreter (for bootstrapping the venv). + const interpreter = await env.ensureInterpreter(); + checks.push( + check("interpreter", "Python interpreter", interpreter ? "ok" : "fail", { + detail: interpreter ?? "No interpreter found.", + hint: interpreter + ? undefined + : "Install Python (3.9+) and select an interpreter via the Python extension.", + }), + ); + + // 3. Tooling: uv (preferred) vs stdlib venv. Informational unless the + // venv is missing AND the stdlib module is unavailable. + const hasUv = await env.hasUv(); + const venvOk = await env.venvExists(courseRoot); + if (hasUv) { + checks.push( + check("tooling", "Environment tooling", "ok", { + detail: "uv detected — fast environment creation.", + }), + ); + } else if (interpreter) { + // Only probe the stdlib venv module when we'd actually need it. + const venvModuleOk = venvOk + ? true + : await env.venvModuleSupported(interpreter); + checks.push( + check( + "tooling", + "Environment tooling", + venvModuleOk ? "warn" : "fail", + { + detail: venvModuleOk + ? "Using the standard-library `venv` (install `uv` for faster setup)." + : "The `venv`/`ensurepip` modules are missing from this Python.", + hint: venvModuleOk + ? undefined + : "On Debian/Ubuntu install them with `sudo apt install python3-venv` " + + "(matching your Python version, e.g. `python3.12-venv`).", + }, + ), + ); + } + + // 4. The per-course virtual environment. + checks.push( + check("venv", "Course virtual environment", venvOk ? "ok" : "fail", { + detail: env.venvUri(courseRoot).fsPath, + hint: venvOk + ? undefined + : "Run environment setup to create the course virtual environment.", + fixes: venvOk + ? undefined + : [{ label: "Set up environment", kind: "setup" }], + }), + ); + + const venvPython = venvOk ? await env.venvPython(courseRoot) : undefined; + checks.push( + check( + "venv-interpreter", + "Environment interpreter", + !venvOk ? "skip" : venvPython ? "ok" : "fail", + { + detail: !venvOk + ? "No environment yet." + : (venvPython ?? "The venv exists but has no interpreter."), + hint: + venvOk && !venvPython + ? "The environment looks corrupt; re-run setup to recreate it." + : undefined, + fixes: + venvOk && !venvPython + ? [{ label: "Set up environment", kind: "setup" }] + : undefined, + }, + ), + ); + + // 5. Required packages import in the venv. + if (venvPython) { + const report = await env.importsReport(courseRoot, [ + "qdk", + "qsharp_widgets", + ]); + const missing = report.filter((r) => !r.ok).map((r) => r.module); + checks.push( + check( + "packages", + "Required packages", + missing.length === 0 ? "ok" : "fail", + { + detail: + missing.length === 0 + ? report.map((r) => r.module).join(", ") + : `Missing or broken: ${missing.join(", ")}`, + hint: + missing.length === 0 + ? undefined + : "Re-run environment setup to (re)install the course's pinned packages.", + fixes: + missing.length === 0 + ? undefined + : [{ label: "Set up environment", kind: "setup" }], + }, + ), + ); + } else { + checks.push( + check("packages", "Required packages", "skip", { + detail: "No environment yet.", + }), + ); + } + + return this.assembleReport(course, checks); + } + + /** + * Fold a list of diagnostic checks into an {@link EnvironmentCheckReport}: + * compute the overall status, a human summary, and the de-duplicated fix + * list. + */ + private assembleReport( + course: CatalogCourse, + checks: EnvironmentCheckItem[], + ): EnvironmentCheckReport { + const hasFail = checks.some((c) => c.status === "fail"); + const hasWarn = checks.some((c) => c.status === "warn"); + const overallStatus: EnvironmentStatus = hasFail + ? "error" + : hasWarn + ? "warning" + : "ok"; + + // De-duplicate fixes by kind+label, preserving first-seen order. + const fixes: EnvironmentCheckFix[] = []; + const seen = new Set(); + for (const c of checks) { + for (const r of c.fixes ?? []) { + const key = `${r.kind}:${r.label}`; + if (!seen.has(key)) { + seen.add(key); + fixes.push(r); + } + } + } + + const failed = checks.filter((c) => c.status === "fail").length; + const warned = checks.filter((c) => c.status === "warn").length; + const summary = + overallStatus === "ok" + ? `"${course.title}" is ready to go.` + : overallStatus === "warning" + ? `"${course.title}" works, but ${warned} thing${warned === 1 ? "" : "s"} could be improved.` + : `"${course.title}" has ${failed} problem${failed === 1 ? "" : "s"} to fix before it will run.`; + + return { + courseId: course.id, + overallStatus, + summary, + checks, + fixes, + }; + } + + /** + * Switch the active course. Lazily loads the course (and scaffolds its + * files) if it isn't loaded yet, moves the position to the first + * incomplete activity, persists, and fires change events. + */ + async switchCourse( + courseId: string, + source?: TelemetrySource, + ): Promise { + const ws = this.requireWorkspace(); + let course = ws.courses.get(courseId); + if (!course) { + course = await ws.registry.loadCourse(courseId); + ws.courses.set(course.id, course); + await this.scaffoldCourse(ws, course); + } + if (course.kind === "python-notebook") { + void this.pythonRunner.promptInstallExtensions(); + void this.ensureEnvironment(course); + } + ws.progressData.position = this.firstIncompletePosition(course); + await this.saveProgress(); + this.startSentinelWatcher(); + const state = this.getState(); + this._onDidChangeState.fire(state); + if (source) { + this.sendActivityActionTelemetry("navigate", source); + } + return state; + } + + /** + * The first activity in a course that has not been completed, or the + * course's first activity when everything is already complete. + */ + private firstIncompletePosition(course: CatalogCourse): ActivityLocation { + for (const unit of course.units) { + for (const activity of unit.activities) { + const location: ActivityLocation = { + courseId: course.id, + unitId: unit.id, + activityId: activity.id, + }; + if (!this.isComplete(location)) { + return location; + } + } + } + const first = course.units[0]; + return { + courseId: course.id, + unitId: first?.id ?? "", + activityId: first?.activities[0]?.id ?? "", + }; + } + + listUnits(): UnitSummary[] { + const course = this.activeCourse; let foundFirstIncomplete = false; - return ws.catalog.units.map((kata) => { + return course.units.map((kata) => { const activityCount = kata.activities.length; let completedCount = 0; for (const activity of kata.activities) { if ( this.findCompletion({ - courseId: ws.catalog.id, + courseId: course.id, unitId: kata.id, activityId: activity.id, }) @@ -311,14 +945,33 @@ export class LearningService { } getProgress(): OverallProgress { + return this.computeProgress(this.activeCourse); + } + + /** + * Compute progress for an arbitrary course, lazily loading it if needed. + * Does **not** change the active course or position. Used to populate + * per-course progress badges in the tree view. + */ + async getCourseProgress(courseId: string): Promise { + const ws = this.requireWorkspace(); + let course = ws.courses.get(courseId); + if (!course) { + course = await ws.registry.loadCourse(courseId); + ws.courses.set(course.id, course); + } + return this.computeProgress(course); + } + + private computeProgress(course: CatalogCourse): OverallProgress { const ws = this.requireWorkspace(); let totalActivities = 0; let completedActivities = 0; - const units: UnitProgress[] = ws.catalog.units.map((k) => { + const units: UnitProgress[] = course.units.map((k) => { const activities: ActivityProgress[] = k.activities.map((s) => { const completion = this.findCompletion({ - courseId: ws.catalog.id, + courseId: course.id, unitId: k.id, activityId: s.id, }); @@ -354,8 +1007,11 @@ export class LearningService { result: HintContext | null; state: LearningState; } { - const exercise = this.resolveExercise(); + if (source) { + this.sendActivityActionTelemetry("hint", source); + } + const exercise = this.resolveExercise(); const hints = exercise.hints; const solutionExplanation = exercise.solutionExplanation; @@ -363,10 +1019,6 @@ export class LearningService { return { result: null, state: this.getState() }; } - if (source) { - this.sendActivityActionTelemetry("hint", source); - } - return { result: { hints, solutionExplanation }, state: this.getState(), @@ -374,11 +1026,11 @@ export class LearningService { } getAllSolutions(source?: TelemetrySource): string[] { - const exercise = this.resolveExercise(); if (source) { this.sendActivityActionTelemetry("solution", source); } - return exercise.solutionCodes; + + return this.resolveExercise().solutionCodes; } getExerciseFileUri(): vscode.Uri { @@ -432,6 +1084,14 @@ export class LearningService { } getCurrentCodeFileUri(): vscode.Uri | undefined { + // Python-notebook courses: the "code" is the notebook itself. + if (this.activeCourse.kind === "python-notebook") { + const { unit } = this.findCurrentActivity(); + if (unit.notebookRel) { + return this.notebookFileUri(unit.notebookRel); + } + return undefined; + } const { activity } = this.findCurrentActivity(); if (activity.type === "exercise") { return this.getExerciseFileUri(); @@ -443,10 +1103,44 @@ export class LearningService { } /** - * Reset the current exercise file to the original placeholder code - * and clear its completion status. + * Reset the current exercise/unit to its original state and clear + * completion status. */ async resetExercise(source?: TelemetrySource): Promise { + // Python-notebook courses: close the notebook, re-copy the entire unit + // from source, and clear completion. + if (this.activeCourse.kind === "python-notebook") { + const { unit } = this.findCurrentActivity(); + // Close any open notebook tabs for this unit. + if (unit.notebookRel) { + const notebookUri = this.notebookFileUri(unit.notebookRel); + await this.closeNotebookTab(notebookUri); + } + // Re-materialize the unit from source. + await this.pythonRunner.rematerializeUnit(this.activeCourse, unit.id); + // Delete the sentinel file if present. + if (unit.notebookRel) { + const unitDir = this.notebookFileUri(unit.notebookRel); + const sentinelUri = vscode.Uri.joinPath( + unitDir, + "..", + ".qdk-unit-complete", + ); + try { + await vscode.workspace.fs.delete(sentinelUri); + } catch { + // may not exist + } + } + this.markIncomplete(this.requireWorkspace().progressData.position); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + if (source) { + this.sendActivityActionTelemetry("reset", source); + } + return; + } + const exercise = this.resolveExercise(); const uri = this.getExerciseFileUri(); // Save any unsaved edits first so the editor is clean, then overwrite @@ -473,10 +1167,6 @@ export class LearningService { if (activity.type === "exercise") { throw new Error("Exercises cannot be run. Use checkSolution() instead."); } - const fileUri = this.getCurrentCodeFileUri(); - if (!fileUri) { - throw new Error("Current activity cannot be run."); - } if (activity.type === "lesson" && activity.example) { await this.markExampleRun(); @@ -486,6 +1176,25 @@ export class LearningService { this.sendActivityActionTelemetry("run", source); } + // Python-notebook courses use native VS Code notebook execution. + if (this.activeCourse.kind === "python-notebook") { + return { + result: { + success: false, + messages: [], + error: + "This course uses native notebook execution. " + + "Run cells directly in the notebook.", + }, + state: this.getState(), + }; + } + + const fileUri = this.getCurrentCodeFileUri(); + if (!fileUri) { + throw new Error("Current activity cannot be run."); + } + const doc = await vscode.workspace.openTextDocument(fileUri); const programResult = await getProgramForDocument(doc); if (!programResult.success) { @@ -514,8 +1223,27 @@ export class LearningService { this.sendActivityActionTelemetry("check", source); } + // Python-notebook courses use in-notebook verification via + // complete_unit(). The extension detects completion via the sentinel + // file watcher, not through this method. + if (this.activeCourse.kind === "python-notebook") { + return { + result: { + passed: false, + messages: [], + error: + "This course uses native notebook execution. " + + "Run all cells in the notebook, including the final " + + "complete_unit() cell, to mark the unit complete.", + }, + state: this.getState(), + }; + } + const exercise = this.resolveExercise(); const userCode = await this.readUserCode(); + // Drop-in courses carry their own verification sources inline; the + // built-in katas resolve them from the bundled content by `sourceIds`. const exerciseSources = await getExerciseSources( // CatalogExercise is structurally incompatible with Exercise (different // description/solution shapes), but getExerciseSources only reads sourceIds. @@ -663,6 +1391,7 @@ export class LearningService { detected.learningContentRoot, ); this.startWatcher(); + this.startSentinelWatcher(); sendTelemetryEvent( EventType.LearningSessionStarted, { isFirstTime: "false" }, @@ -693,6 +1422,7 @@ export class LearningService { this._writingProgress = false; } this.startWatcher(); + this.startSentinelWatcher(); sendTelemetryEvent( EventType.LearningSessionStarted, { isFirstTime: "true" }, @@ -707,7 +1437,23 @@ export class LearningService { ): Promise { const learningFile = vscode.Uri.joinPath(workspaceRoot, LEARNING_FILE); - const course = await loadKatasCourse(); + const registry = createCourseRegistry(workspaceRoot); + + // Eagerly load all available courses so that the saved position + // (which may reference a drop-in course) resolves correctly. + const courses = new Map(); + const descriptors = await registry.listCourses(); + for (const descriptor of descriptors) { + try { + const course = await registry.loadCourse(descriptor.id); + courses.set(course.id, course); + } catch { + // Skip courses that fail to load. + } + } + + const defaultCourse = + courses.get(KATAS_COURSE_ID) ?? courses.values().next().value; // Build workspace state; assigned to this.workspace only after all // async setup succeeds so that `initialized` stays false on failure. @@ -715,26 +1461,34 @@ export class LearningService { workspaceRoot, learningContentRoot: katasRoot, learningFile, - catalog: course, + courses, + registry, progressData: { version: 1, position: { - courseId: course.id, - unitId: course.units[0]?.id ?? "", - activityId: course.units[0]?.activities[0]?.id ?? "", + courseId: defaultCourse?.id ?? "", + unitId: defaultCourse?.units[0]?.id ?? "", + activityId: defaultCourse?.units[0]?.activities[0]?.id ?? "", }, completions: {}, startedAt: new Date().toISOString(), }, }; - await this.scaffoldExercises(ws); - await this.scaffoldExamples(ws); await this.loadProgress(ws); - // All async setup succeeded — publish the workspace. + // Publish the workspace before scaffolding so that methods relying on + // `requireWorkspace()` can resolve. this.workspace = ws; this.syncContextKey(); + + for (const course of courses.values()) { + try { + await this.scaffoldCourse(ws, course); + } catch { + // A failing scaffold should not block workspace initialization. + } + } } private requireWorkspace(): WorkspaceState { @@ -746,6 +1500,20 @@ export class LearningService { return this.workspace; } + /** The currently-active course, resolved from the progress position. */ + private get activeCourse(): CatalogCourse { + const ws = this.requireWorkspace(); + return this.requireCourse(ws, ws.progressData.position.courseId); + } + + private requireCourse(ws: WorkspaceState, courseId: string): CatalogCourse { + const course = ws.courses.get(courseId); + if (!course) { + throw new Error(`Course not loaded: ${courseId}`); + } + return course; + } + private syncContextKey(): void { void vscode.commands.executeCommand( "setContext", @@ -768,7 +1536,54 @@ export class LearningService { /** Builds the button groups shown in the webview toolbar for the current activity. */ private getAvailableActions(): ActionGroup[] { - const { activity } = this.findCurrentActivity(); + const { activity, unit } = this.findCurrentActivity(); + + // Python-notebook courses: primary action is "Open Notebook" (or + // "Next" if the unit is already complete). + if (this.activeCourse.kind === "python-notebook" && unit.notebookRel) { + const isComplete = this.isComplete(this.position); + const primaryGroup: ActionGroup = isComplete + ? [{ key: "space", label: "Next", action: "next", primary: true }] + : [ + { + key: "space", + label: "Open Notebook", + action: "open-notebook", + primary: true, + codicon: "notebook", + }, + ]; + const extraGroups: ActionGroup[] = isComplete + ? [ + [ + { + key: "o", + label: "Open Notebook", + action: "open-notebook", + codicon: "notebook", + }, + { key: "r", label: "Reset", action: "reset" }, + ], + ] + : [ + [ + { + key: "h", + label: "Hint", + action: "hint-chat", + codicon: "sparkle", + }, + { key: "r", label: "Reset", action: "reset" }, + ], + ]; + const navGroup: ActionGroup = [ + { key: "b", label: "Back", action: "back" }, + ]; + return [primaryGroup, ...extraGroups, navGroup].filter( + (g) => g.length > 0, + ); + } + const primary = this.getPrimaryAction(); const primaryLabel: Record = { @@ -833,6 +1648,54 @@ export class LearningService { ); } + /** + * Actions for the panel in python-notebook courses. Checks whether + * the entire unit is complete (all exercises done) rather than a + * single activity. + */ + private getAvailableActionsForPanel(unit: CatalogUnit): ActionGroup[] { + const course = this.activeCourse; + const unitComplete = unit.activities + .filter((a) => a.type === "exercise") + .every((a) => + this.isComplete({ + courseId: course.id, + unitId: unit.id, + activityId: a.id, + }), + ); + + const primaryGroup: ActionGroup = unitComplete + ? [{ key: "space", label: "Next", action: "next", primary: true }] + : [ + { + key: "space", + label: "Open Notebook", + action: "open-notebook", + primary: true, + codicon: "notebook", + }, + ]; + + const extraGroups: ActionGroup[] = unitComplete + ? [ + [ + { + key: "o", + label: "Open Notebook", + action: "open-notebook", + codicon: "notebook", + }, + { key: "r", label: "Reset", action: "reset" }, + ], + ] + : [[{ key: "r", label: "Reset", action: "reset" }]]; + + const navGroup: ActionGroup = [{ key: "b", label: "Back", action: "back" }]; + + return [primaryGroup, ...extraGroups, navGroup].filter((g) => g.length > 0); + } + /** Turns a catalog activity into the typed content payload (exercise, lesson-example, or lesson-text). */ private resolveActivityContent( location: ActivityLocation, @@ -842,6 +1705,15 @@ export class LearningService { const ws = this.requireWorkspace(); if (activity.type === "exercise") { + // Python-notebook exercises live in the notebook — show their + // description as lesson text so the panel renders something useful. + if (this.activeCourse.kind === "python-notebook") { + return { + type: "lesson-text", + content: activity.description, + } satisfies LessonTextContent; + } + const fileUri = vscode.Uri.joinPath( ws.learningContentRoot, "exercises", @@ -884,6 +1756,11 @@ export class LearningService { } satisfies LessonTextContent; } + /** Working-copy (`*.workbook.ipynb`) URI of a notebook for the active python-notebook course. */ + private notebookFileUri(notebookRel: string): vscode.Uri { + return this.pythonRunner.workbookFileUri(this.activeCourse, notebookRel); + } + private findCurrentActivity(): { unit: CatalogUnit; activity: CatalogActivity; @@ -909,13 +1786,13 @@ export class LearningService { private nextActivity( location: ActivityLocation, ): ActivityLocation | undefined { - const ws = this.requireWorkspace(); + const course = this.activeCourse; let found = false; - for (const unit of ws.catalog.units) { + for (const unit of course.units) { for (const a of unit.activities) { if (found) { return { - courseId: ws.catalog.id, + courseId: course.id, unitId: unit.id, activityId: a.id, }; @@ -932,15 +1809,15 @@ export class LearningService { private previousActivity( location: ActivityLocation, ): ActivityLocation | undefined { - const ws = this.requireWorkspace(); + const course = this.activeCourse; let prev: ActivityLocation | undefined; - for (const unit of ws.catalog.units) { + for (const unit of course.units) { for (const a of unit.activities) { if (unit.id === location.unitId && a.id === location.activityId) { return prev; } prev = { - courseId: ws.catalog.id, + courseId: course.id, unitId: unit.id, activityId: a.id, }; @@ -950,9 +1827,7 @@ export class LearningService { } private findUnit(unitId: string): CatalogUnit { - const kata = this.requireWorkspace().catalog.units.find( - (k) => k.id === unitId, - ); + const kata = this.activeCourse.units.find((k) => k.id === unitId); if (!kata) { throw new Error(`Unit not found: ${unitId}`); } @@ -966,7 +1841,8 @@ export class LearningService { await this.saveProgress(); this._onDidChangeState.fire(this.getState()); - const units = this.requireWorkspace().catalog.units; + // TODO (acasey): do we actually want telemetry for other courses? + const units = this.activeCourse.units; const unitIndex = units.findIndex((u) => u.id === location.unitId); const unit = unitIndex >= 0 ? units[unitIndex] : undefined; const exercises = @@ -999,11 +1875,19 @@ export class LearningService { parsed.position !== null ) { ws.progressData = parsed as ProgressFileData; + // Resolve the course the saved position points at, falling back to + // the default loaded course if it references one not yet loaded. + const course = + ws.courses.get(ws.progressData.position.courseId) ?? + this.defaultCourseOf(ws); // Validate saved position references a known unit and activity - if (ws.catalog.units.length > 0) { - const unit = ws.catalog.units.find( - (k) => k.id === ws.progressData.position.unitId, - ); + if (course && course.units.length > 0) { + const unit = + ws.progressData.position.courseId === course.id + ? course.units.find( + (k) => k.id === ws.progressData.position.unitId, + ) + : undefined; const activityValid = unit && unit.activities.some( @@ -1011,9 +1895,9 @@ export class LearningService { ); if (!activityValid) { ws.progressData.position = { - courseId: ws.catalog.id, - unitId: ws.catalog.units[0].id, - activityId: ws.catalog.units[0].activities[0]?.id ?? "", + courseId: course.id, + unitId: course.units[0].id, + activityId: course.units[0].activities[0]?.id ?? "", }; } } @@ -1022,18 +1906,24 @@ export class LearningService { } catch { // expected when file is missing or corrupt } + const course = this.defaultCourseOf(ws); ws.progressData = { version: 1, position: { - courseId: ws.catalog.id, - unitId: ws.catalog.units[0]?.id ?? "", - activityId: ws.catalog.units[0]?.activities[0]?.id ?? "", + courseId: course?.id ?? "", + unitId: course?.units[0]?.id ?? "", + activityId: course?.units[0]?.activities[0]?.id ?? "", }, completions: {}, startedAt: new Date().toISOString(), }; } + /** The default course for a workspace (built-in katas, else the first loaded). */ + private defaultCourseOf(ws: WorkspaceState): CatalogCourse | undefined { + return ws.courses.get(KATAS_COURSE_ID) ?? ws.courses.values().next().value; + } + private async saveProgress(): Promise { const ws = this.requireWorkspace(); const json = JSON.stringify(ws.progressData, null, 2); @@ -1128,47 +2018,135 @@ export class LearningService { this._onDidChangeProgress.fire(this._lastSnapshot); } - private async scaffoldExercises(ws: WorkspaceState): Promise { - for (const kata of ws.catalog.units) { - for (const activity of kata.activities) { - if (activity.type !== "exercise") { - continue; + /** + * Start watching for `.qdk-unit-complete` sentinel files in the active + * python-notebook course folder. When the notebook's `complete_unit()` writes this + * file, we mark the unit complete. + */ + private startSentinelWatcher(): void { + this.stopSentinelWatcher(); + const course = this.activeCourse; + if (course.kind !== "python-notebook" || !course.sourceDir) { + return; + } + const coursesDir = vscode.Uri.parse(course.sourceDir); + const pattern = new vscode.RelativePattern( + coursesDir, + "**/.qdk-unit-complete", + ); + this._sentinelWatcher = vscode.workspace.createFileSystemWatcher(pattern); + + const onSentinel = async (uri: vscode.Uri) => { + try { + const bytes = await vscode.workspace.fs.readFile(uri); + const unitId = new TextDecoder().decode(bytes).trim(); + if (!unitId) { + return; } - const fileUri = vscode.Uri.joinPath( - ws.learningContentRoot, - "exercises", - kata.id, - `${activity.id}.qs`, - ); - if (await this.uriExists(fileUri)) { - continue; + // Find the unit and mark all its activities complete. + const unit = course.units.find((u) => u.id === unitId); + if (!unit || unit.activities.length === 0) { + return; } - await this.ensureParentDir(fileUri); - await vscode.workspace.fs.writeFile( - fileUri, - new TextEncoder().encode(activity.placeholderCode), - ); + let changed = false; + for (const activity of unit.activities) { + const location: ActivityLocation = { + courseId: course.id, + unitId: unit.id, + activityId: activity.id, + }; + if (!this.isComplete(location)) { + this.markComplete(location); + changed = true; + } + } + if (changed) { + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + } + } catch { + // sentinel may be transient or corrupt; ignore } + }; + + this._sentinelWatcher.onDidCreate(onSentinel); + this._sentinelWatcher.onDidChange(onSentinel); + } + + private stopSentinelWatcher(): void { + this._sentinelWatcher?.dispose(); + this._sentinelWatcher = undefined; + } + + /** + * Close any open editor tabs whose URI matches the given notebook URI. + */ + private async closeNotebookTab(uri: vscode.Uri): Promise { + const uriStr = uri.toString(); + const tabs: vscode.Tab[] = []; + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if ( + tab.input instanceof vscode.TabInputNotebook && + tab.input.uri.toString() === uriStr + ) { + tabs.push(tab); + } + } + } + if (tabs.length > 0) { + await vscode.window.tabGroups.close(tabs); } } - private async scaffoldExamples(ws: WorkspaceState): Promise { - for (const kata of ws.catalog.units) { + /** + * Materialize the editable files (exercise placeholders and example code) + * for a Q# course into the learning content folder. No-op for non-qsharp + * courses (those are scaffolded by their own runtime). + */ + private async scaffoldCourse( + ws: WorkspaceState, + course: CatalogCourse, + ): Promise { + if (course.kind === "python-notebook") { + // Copy the course's notebooks into the workspace working copy so the + // learner edits a stable location, then surface any missing tooling. + await this.pythonRunner.materializeCourse(course); + return; + } + if (course.kind !== "qsharp") { + return; + } + for (const kata of course.units) { for (const activity of kata.activities) { - if (activity.type !== "lesson" || !activity.example) { - continue; + if (activity.type === "exercise") { + const fileUri = vscode.Uri.joinPath( + ws.learningContentRoot, + "exercises", + kata.id, + `${activity.id}.qs`, + ); + if (await this.uriExists(fileUri)) { + continue; + } + await this.ensureParentDir(fileUri); + await vscode.workspace.fs.writeFile( + fileUri, + new TextEncoder().encode(activity.placeholderCode), + ); + } else if (activity.type === "lesson" && activity.example) { + const fileUri = vscode.Uri.joinPath( + ws.learningContentRoot, + "examples", + kata.id, + `${activity.example.id}.qs`, + ); + await this.ensureParentDir(fileUri); + await vscode.workspace.fs.writeFile( + fileUri, + new TextEncoder().encode(activity.example.code), + ); } - const fileUri = vscode.Uri.joinPath( - ws.learningContentRoot, - "examples", - kata.id, - `${activity.example.id}.qs`, - ); - await this.ensureParentDir(fileUri); - await vscode.workspace.fs.writeFile( - fileUri, - new TextEncoder().encode(activity.example.code), - ); } } } diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index fbbb51a15cc..ee5991effd9 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -75,7 +75,8 @@ export type Action = | "check" | "reset" | "hint-chat" - | "explain-chat"; + | "explain-chat" + | "open-notebook"; export interface ActionBinding { /** Keyboard shortcut key (single character like "b", or "space"). */ @@ -136,6 +137,8 @@ export interface HintContext { } export interface LearningState { + /** The currently-active course. */ + course: { id: string; title: string; kind: CourseKind }; position: CurrentActivity; actions: ActionGroup[]; progress: OverallProgress; @@ -215,7 +218,13 @@ export type WebviewToHostMessage = /** Open Copilot Chat with a learning-context query. */ | { command: "openChat"; text: string } /** Focus the learning progress tree view in the sidebar. */ - | { command: "focusProgress" }; + | { command: "focusProgress" } + /** Switch to a different course. */ + | { command: "switchCourse"; courseId: string } + /** Show README/info for a course (defaults to the active course). */ + | { command: "courseInfo"; courseId?: string } + /** Open the course picker to browse and switch courses. */ + | { command: "browseCourses" }; // ─── Catalog ─── // @@ -256,16 +265,98 @@ export interface CatalogLesson { export type CatalogActivity = CatalogExercise | CatalogLesson; +/** + * Exercise metadata loaded from a per-unit `_exercises.json` sidecar + * (python-notebook courses). Provides hints, solutions, and descriptions + * for the chat LM tools without requiring cell parsing or execution. + */ +export interface NotebookExerciseInfo { + id: string; + title: string; + description: string; + hints: string[]; + solution: string; + solutionExplanation: string; + /** 1-based cell index in the notebook where this exercise lives. */ + cellIndex?: number; +} + export interface CatalogUnit { id: string; title: string; activities: CatalogActivity[]; + /** + * Exercise metadata for python-notebook courses, loaded from + * `_exercises.json`. Used by chat LM tools for hints/solutions. + */ + notebookExercises?: NotebookExerciseInfo[]; + /** + * Path (relative to the course source dir) of the notebook for this + * unit. Set for python-notebook courses. + */ + notebookRel?: string; } +/** The execution model for a course's activities. */ +export type CourseKind = "qsharp" | "python-notebook"; + export interface CatalogCourse { id: string; title: string; + /** Execution model for this course. Defaults to `"qsharp"`. */ + kind: CourseKind; units: CatalogUnit[]; + /** + * URI string of the folder the course was loaded from (drop-in courses + * only). Used to locate notebooks and other assets for materialization. + */ + sourceDir?: string; + /** Environment requirements (python-notebook courses). */ + environment?: CourseEnvironment; +} + +/** + * Lightweight metadata describing a course that can be loaded by the + * {@link CourseRegistry}. Used to populate course pickers and the tree + * view without forcing a full course load. + */ +export interface CourseDescriptor { + id: string; + title: string; + shortDescription?: string; + kind: CourseKind; + /** Optional path (URI string) to a README rendered for "Course info". */ + readmePath?: string; + /** Optional environment requirements (used by python-notebook courses). */ + environment?: CourseEnvironment; +} + +/** + * Environment requirements for a course (python-notebook courses). + * + * Courses that ship a `pyproject.toml` use `uv sync` for environment setup; + * the `python` and `requirements` fields are only used as a legacy fallback + * when no `pyproject.toml` is present. + */ +export interface CourseEnvironment { + /** + * Python version specifier for the course venv (e.g. `">=3.11"`, `"3.12"`). + * Legacy: used only when no `pyproject.toml` is present. Prefer declaring + * `requires-python` in `pyproject.toml` instead. + */ + python?: string; + /** + * Python package requirements (e.g. `["qdk[jupyter]>=1.0", "ipympl"]`). + * Legacy: used only when no `pyproject.toml` is present. Prefer declaring + * `dependencies` in `pyproject.toml` instead. + */ + requirements?: string[]; + /** + * Module names to probe with `importlib.util.find_spec` in the notebook's + * environment check cell (e.g. `["qdk", "qdk.widgets"]`). These are + * importable module names, not pip package names. + */ + importChecks?: string[]; } export interface UnitSummary { @@ -276,3 +367,53 @@ export interface UnitSummary { /** True if this is the first unit that hasn't been fully completed. */ firstIncomplete: boolean; } + +// ─── Environment check (environment diagnostics) ─── + +/** Severity of a single {@link EnvironmentCheckItem}. */ +export type EnvironmentCheckStatus = "ok" | "warn" | "fail" | "skip"; + +/** A suggested fix attached to a failing {@link EnvironmentCheckItem}. */ +export interface EnvironmentCheckFix { + /** Short label for the action (e.g. "Set up environment"). */ + label: string; + /** + * What the fix does when chosen: + * - `setup`: run the per-course environment setup. + * - `install-extensions`: prompt to install Python/Jupyter. + * - `select-kernel`: re-select the course kernel for the notebook. + * - `docs`: informational only; no action. + */ + kind: "setup" | "install-extensions" | "select-kernel" | "docs"; +} + +/** One diagnostic in an {@link EnvironmentCheckReport}. */ +export interface EnvironmentCheckItem { + /** Stable identifier for the check (e.g. `"venv"`). */ + id: string; + /** Human-readable label. */ + label: string; + /** Pass/warn/fail/skip. */ + status: EnvironmentCheckStatus; + /** Extra detail (a path, version, or error message). */ + detail?: string; + /** Guidance on how to fix a non-ok check. */ + hint?: string; + /** Optional fixes the UI can offer for this check. */ + fixes?: EnvironmentCheckFix[]; +} + +/** Overall status for an {@link EnvironmentCheckReport}. */ +export type EnvironmentStatus = "ok" | "warning" | "error"; + +/** Structured result of running environment diagnostics for a course. */ +export interface EnvironmentCheckReport { + courseId: string; + /** Overall status across all checks. */ + overallStatus: EnvironmentStatus; + /** One-line human summary of the overall status. */ + summary: string; + checks: EnvironmentCheckItem[]; + /** Distinct fixes aggregated from all failing checks, in priority order. */ + fixes: EnvironmentCheckFix[]; +} diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 615e97b0245..4feea84b8ee 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -225,7 +225,7 @@ function App() { return ( <> - +
    + vscodeApi.postMessage({ command: "courseInfo", courseId: course.id }); + const onBrowse = () => vscodeApi.postMessage({ command: "browseCourses" }); return (
    - Microsoft Quantum Katas + {course.title} + + + +
    ); } diff --git a/source/vscode/src/learning/webview/webview.css b/source/vscode/src/learning/webview/webview.css index f26406a1a7b..0aa1738558a 100644 --- a/source/vscode/src/learning/webview/webview.css +++ b/source/vscode/src/learning/webview/webview.css @@ -130,6 +130,29 @@ body { color: var(--muted); } +/* Course actions (info / browse) pushed to the right of the branding bar. */ +.branding-actions { + margin-left: auto; + display: flex; + gap: 10px; + flex-shrink: 0; +} + +.link-button { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--vscode-textLink-foreground, #3794ff); + font-size: var(--font-xs); + font-family: inherit; +} + +.link-button:hover { + text-decoration: underline; +} + /* Breadcrumb bar showing "Unit › Activity" and a type badge. */ .header { display: flex; diff --git a/source/vscode/test/buildTests.mjs b/source/vscode/test/buildTests.mjs index 1d269586e21..bdfb24d2e53 100644 --- a/source/vscode/test/buildTests.mjs +++ b/source/vscode/test/buildTests.mjs @@ -27,6 +27,7 @@ const platformBuildOptions = { join(thisDir, "suites", "empty", "index.browser.ts"), join(thisDir, "suites", "language-service", "index.browser.ts"), join(thisDir, "suites", "debugger", "index.browser.ts"), + join(thisDir, "suites", "learning", "index.browser.ts"), ], platform: "browser", outdir: join(thisDir, "out", "browser"), @@ -37,6 +38,7 @@ const platformBuildOptions = { entryPoints: [ join(thisDir, "suites", "language-service", "index.node.ts"), join(thisDir, "suites", "debugger", "index.node.ts"), + join(thisDir, "suites", "learning", "index.node.ts"), ], platform: "node", outdir: join(thisDir, "out", "node"), diff --git a/source/vscode/test/runTests.mjs b/source/vscode/test/runTests.mjs index c2d8df49529..d817e157f36 100644 --- a/source/vscode/test/runTests.mjs +++ b/source/vscode/test/runTests.mjs @@ -11,7 +11,7 @@ // it in a headless instance of Chromium to run the integration test suite. // // Command-line arguments: -// --suite= Run only the specified test suite (language-service or debugger) +// --suite= Run only the specified test suite (language-service, debugger, or learning) // --waitForDebugger= Wait for debugger to attach on the specified port before running tests // --verbose Enable verbose logging for VS Code and test web server // Note: This controls the VS Code and test web server logging level. @@ -72,7 +72,7 @@ try { } console.log("Empty suite succeeded."); - const suites = ["language-service", "debugger"]; + const suites = ["language-service", "debugger", "learning"]; const toRun = selectedSuite && suites.includes(selectedSuite) ? [selectedSuite] : suites; diff --git a/source/vscode/test/suites/extensionUtils.ts b/source/vscode/test/suites/extensionUtils.ts index e0eeaec93a6..99528997a4f 100644 --- a/source/vscode/test/suites/extensionUtils.ts +++ b/source/vscode/test/suites/extensionUtils.ts @@ -32,7 +32,7 @@ export function setTestGithubEndpoint(url: string) { testGithubEndpoint = url; } -export async function activateExtension() { +export async function activateExtension(): Promise { // Check for pre-release or stable builds of the extension, as could be in release pipeline const ext = vscode.extensions.getExtension("quantum.qsharp-lang-vscode-dev") ?? @@ -43,7 +43,7 @@ export async function activateExtension() { } if (ext.isActive) { - return; + return ext.exports as ExtensionApi; } const start = performance.now(); @@ -73,6 +73,8 @@ export async function activateExtension() { console.log( `qsharp-tests: activate() completed in ${performance.now() - start}ms`, ); + + return extensionApi; } /** diff --git a/source/vscode/test/suites/learning/index.browser.ts b/source/vscode/test/suites/learning/index.browser.ts new file mode 100644 index 00000000000..fa243e23f93 --- /dev/null +++ b/source/vscode/test/suites/learning/index.browser.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { runMochaTests } from "../runBrowser"; + +export function run(): Promise { + return runMochaTests(() => { + // We can't use any wildcards or dynamically discovered + // paths here since ESBuild needs these modules to be + // real paths on disk at bundling time. + require("./learning.test"); // eslint-disable-line @typescript-eslint/no-require-imports + }); +} diff --git a/source/vscode/test/suites/learning/index.node.ts b/source/vscode/test/suites/learning/index.node.ts new file mode 100644 index 00000000000..5b518e57034 --- /dev/null +++ b/source/vscode/test/suites/learning/index.node.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { runMochaTests } from "../runNode"; + +export async function run(): Promise { + await runMochaTests(() => { + // We can't use any wildcards or dynamically discovered + // paths here since ESBuild needs these modules to be + // real paths on disk at bundling time. + require("./learning.test"); // eslint-disable-line @typescript-eslint/no-require-imports + }); +} diff --git a/source/vscode/test/suites/learning/learning.test.ts b/source/vscode/test/suites/learning/learning.test.ts new file mode 100644 index 00000000000..8eff6ea905d --- /dev/null +++ b/source/vscode/test/suites/learning/learning.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { assert } from "chai"; +import { type ExtensionApi } from "../../src/extension"; +import { activateExtension } from "../extensionUtils"; + +type LearningService = NonNullable; + +suite("QDK Learning multi-course", function suite() { + let service: LearningService; + + this.beforeAll(async function beforeAll() { + const api = await activateExtension(); + // The learning feature is desktop-only, so this suite is skipped in the + // web (browser) test host where no learning service is exposed. + if (!api.learning) { + this.skip(); + } + service = api.learning!; + await service.tryInitialize({ createIfMissing: true }); + }); + + test("Katas is the default course", async () => { + const courses = await service.getCourses(); + assert.isTrue( + courses.some((c) => c.id === "katas"), + "the built-in Katas course should always be available", + ); + assert.equal(service.getActiveCourseId(), "katas"); + }); + + test("Drop-in python-notebook course is discovered", async function test() { + const courses = await service.getCourses(); + const descriptor = courses.find((c) => c.id === "circuit-diagrams"); + assert.ok(descriptor, "the fixture course should be discovered"); + assert.equal(descriptor!.kind, "python-notebook"); + }); + + test("Notebook unit parses into a lesson, example, and two tasks", async function test() { + await service.switchCourse("circuit-diagrams", "tree"); + try { + assert.equal(service.getActiveCourseId(), "circuit-diagrams"); + const units = service.listUnits(); + assert.equal(units.length, 1, "course should have a single unit"); + + const progress = service.getProgress(); + const activities = progress.units[0].activities; + const ids = activities.map((a) => a.id); + assert.include(ids, "build-bell"); + assert.include(ids, "display-circuit"); + + const exercises = activities.filter((a) => a.type === "exercise"); + assert.equal(exercises.length, 2, "both tasks should become exercises"); + } finally { + await service.switchCourse("katas", "tree"); + } + }); + + test("Environment check returns a structured report", async function test() { + await service.switchCourse("circuit-diagrams", "tree"); + try { + const report = await service.runEnvironmentCheck(); + assert.equal(report.courseId, "circuit-diagrams"); + assert.isAbove( + report.checks.length, + 0, + "the report should contain checks", + ); + // Until the per-course environment is set up (or on a host without the + // tooling), the report should flag problems and offer a fix. + if (report.overallStatus !== "ok") { + assert.isTrue( + report.fixes.length > 0 || + report.checks.some((c) => c.status !== "ok"), + "a failing report should be actionable", + ); + } + } finally { + await service.switchCourse("katas", "tree"); + } + }); + + test("Katas course needs no environment (check passes)", async () => { + await service.switchCourse("katas", "tree"); + const report = await service.runEnvironmentCheck(); + assert.equal(report.courseId, "katas"); + assert.equal( + report.overallStatus, + "ok", + "Q# courses should pass diagnostics trivially", + ); + assert.isFalse(report.fixes.some((r) => r.kind === "setup")); + }); +}); From 13307b8e1970ae05c5f8dba179945105c21e42b5 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 15 Jul 2026 14:43:09 -0700 Subject: [PATCH 002/101] Add TODO --- source/vscode/src/learning/service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 74b25ffbffd..4f68cbab595 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -768,6 +768,7 @@ export class LearningService { // 5. Required packages import in the venv. if (venvPython) { + // TODO (acasey): are these supposed to come from the course metadata or are these just a baseline for all courses? const report = await env.importsReport(courseRoot, [ "qdk", "qsharp_widgets", From cc7cc6ec61966aa0dcf79efef27e0db00d9b74a0 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 15 Jul 2026 16:51:04 -0700 Subject: [PATCH 003/101] Cleanup and notes from AI review --- source/vscode/src/learning/dropInCourseProvider.ts | 1 + source/vscode/src/learning/panel.ts | 1 + source/vscode/src/learning/service.ts | 6 ++++-- source/vscode/test/suites/learning/learning.test.ts | 13 +++++++++++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index b9e201ab8f8..bb3d7c733c9 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -289,6 +289,7 @@ export class DropInCourseProvider implements CourseProvider { exercises?: unknown; }; if (Array.isArray(parsed.exercises)) { + // TODO (acasey): validate the rest of the parsed input? notebookExercises = parsed.exercises.filter( (e): e is NotebookExerciseInfo => !!e && diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index 056909166f8..b1e8d0d5a6a 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -370,6 +370,7 @@ export class LessonPanelManager { break; } case "reset": { + // TODO (acasey): is this text appropriate for all course flavors? const confirmed = await vscode.window.showWarningMessage( "Reset this unit to the original notebook? Your current work will be lost.", { modal: true }, diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 4f68cbab595..43fa83de4be 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1121,9 +1121,9 @@ export class LearningService { await this.pythonRunner.rematerializeUnit(this.activeCourse, unit.id); // Delete the sentinel file if present. if (unit.notebookRel) { - const unitDir = this.notebookFileUri(unit.notebookRel); + const workingCopyUri = this.notebookFileUri(unit.notebookRel); const sentinelUri = vscode.Uri.joinPath( - unitDir, + workingCopyUri, "..", ".qdk-unit-complete", ); @@ -1446,6 +1446,7 @@ export class LearningService { const descriptors = await registry.listCourses(); for (const descriptor of descriptors) { try { + // TODO (acasey): do this lazily? const course = await registry.loadCourse(descriptor.id); courses.set(course.id, course); } catch { @@ -2152,6 +2153,7 @@ export class LearningService { } } + // TODO (acasey): check for clones private async uriExists(uri: vscode.Uri): Promise { try { await vscode.workspace.fs.stat(uri); diff --git a/source/vscode/test/suites/learning/learning.test.ts b/source/vscode/test/suites/learning/learning.test.ts index 8eff6ea905d..0639c4f4d8f 100644 --- a/source/vscode/test/suites/learning/learning.test.ts +++ b/source/vscode/test/suites/learning/learning.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { assert } from "chai"; -import { type ExtensionApi } from "../../src/extension"; +import { type ExtensionApi } from "../../../src/extension"; import { activateExtension } from "../extensionUtils"; type LearningService = NonNullable; @@ -14,11 +14,19 @@ suite("QDK Learning multi-course", function suite() { const api = await activateExtension(); // The learning feature is desktop-only, so this suite is skipped in the // web (browser) test host where no learning service is exposed. + // TODO (acasey): then why does index.browser.ts invoke this file? if (!api.learning) { this.skip(); } service = api.learning!; - await service.tryInitialize({ createIfMissing: true }); + const foundWorkspace = await service.tryInitialize({ + createIfMissing: true, + }); + if (!foundWorkspace) { + assert.fail( + "No workspace folder — the learning test-workspace fixture is missing", + ); + } }); test("Katas is the default course", async () => { @@ -28,6 +36,7 @@ suite("QDK Learning multi-course", function suite() { "the built-in Katas course should always be available", ); assert.equal(service.getActiveCourseId(), "katas"); + assert.fail("The test ran"); }); test("Drop-in python-notebook course is discovered", async function test() { From afbdf9ccfc0a4ada14361c950edc4110176143e3 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 15 Jul 2026 17:14:56 -0700 Subject: [PATCH 004/101] Move dummy course into test workspace --- .../courses}/circuit-diagrams-new/01-intro/_exercises.json | 0 .../qdk-learning/courses}/circuit-diagrams-new/01-intro/_unit.py | 0 .../courses}/circuit-diagrams-new/01-intro/intro.ipynb | 0 .../qdk-learning/courses}/circuit-diagrams-new/01-intro/intro.md | 0 .../courses}/circuit-diagrams-new/02-circuits/_exercises.json | 0 .../courses}/circuit-diagrams-new/02-circuits/_unit.py | 0 .../courses}/circuit-diagrams-new/02-circuits/circuits.ipynb | 0 .../courses}/circuit-diagrams-new/02-circuits/intro.md | 0 .../qdk-learning/courses}/circuit-diagrams-new/README.md | 0 .../qdk-learning/courses}/circuit-diagrams-new/_check_env.py | 0 .../qdk-learning/courses}/circuit-diagrams-new/_course_lib.py | 0 .../qdk-learning/courses}/circuit-diagrams-new/course.json | 0 .../qdk-learning/courses}/circuit-diagrams-new/pyproject.toml | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/01-intro/_exercises.json (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/01-intro/_unit.py (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/01-intro/intro.ipynb (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/01-intro/intro.md (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/02-circuits/_exercises.json (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/02-circuits/_unit.py (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/02-circuits/circuits.ipynb (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/02-circuits/intro.md (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/README.md (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/_check_env.py (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/_course_lib.py (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/course.json (100%) rename {courses => source/vscode/test/suites/learning/test-workspace/qdk-learning/courses}/circuit-diagrams-new/pyproject.toml (100%) diff --git a/courses/circuit-diagrams-new/01-intro/_exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json similarity index 100% rename from courses/circuit-diagrams-new/01-intro/_exercises.json rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json diff --git a/courses/circuit-diagrams-new/01-intro/_unit.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_unit.py similarity index 100% rename from courses/circuit-diagrams-new/01-intro/_unit.py rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_unit.py diff --git a/courses/circuit-diagrams-new/01-intro/intro.ipynb b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb similarity index 100% rename from courses/circuit-diagrams-new/01-intro/intro.ipynb rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb diff --git a/courses/circuit-diagrams-new/01-intro/intro.md b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md similarity index 100% rename from courses/circuit-diagrams-new/01-intro/intro.md rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md diff --git a/courses/circuit-diagrams-new/02-circuits/_exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json similarity index 100% rename from courses/circuit-diagrams-new/02-circuits/_exercises.json rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json diff --git a/courses/circuit-diagrams-new/02-circuits/_unit.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_unit.py similarity index 100% rename from courses/circuit-diagrams-new/02-circuits/_unit.py rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_unit.py diff --git a/courses/circuit-diagrams-new/02-circuits/circuits.ipynb b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb similarity index 100% rename from courses/circuit-diagrams-new/02-circuits/circuits.ipynb rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb diff --git a/courses/circuit-diagrams-new/02-circuits/intro.md b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/intro.md similarity index 100% rename from courses/circuit-diagrams-new/02-circuits/intro.md rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/intro.md diff --git a/courses/circuit-diagrams-new/README.md b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/README.md similarity index 100% rename from courses/circuit-diagrams-new/README.md rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/README.md diff --git a/courses/circuit-diagrams-new/_check_env.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py similarity index 100% rename from courses/circuit-diagrams-new/_check_env.py rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py diff --git a/courses/circuit-diagrams-new/_course_lib.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py similarity index 100% rename from courses/circuit-diagrams-new/_course_lib.py rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py diff --git a/courses/circuit-diagrams-new/course.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/course.json similarity index 100% rename from courses/circuit-diagrams-new/course.json rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/course.json diff --git a/courses/circuit-diagrams-new/pyproject.toml b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/pyproject.toml similarity index 100% rename from courses/circuit-diagrams-new/pyproject.toml rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/pyproject.toml From 6fcdd63ebc1fe461feb3496f521549f2de07a079 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 15 Jul 2026 17:31:57 -0700 Subject: [PATCH 005/101] Fix tests other than env check --- source/vscode/test/suites/learning/learning.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/source/vscode/test/suites/learning/learning.test.ts b/source/vscode/test/suites/learning/learning.test.ts index 0639c4f4d8f..ba37e024c2d 100644 --- a/source/vscode/test/suites/learning/learning.test.ts +++ b/source/vscode/test/suites/learning/learning.test.ts @@ -36,7 +36,6 @@ suite("QDK Learning multi-course", function suite() { "the built-in Katas course should always be available", ); assert.equal(service.getActiveCourseId(), "katas"); - assert.fail("The test ran"); }); test("Drop-in python-notebook course is discovered", async function test() { @@ -51,13 +50,13 @@ suite("QDK Learning multi-course", function suite() { try { assert.equal(service.getActiveCourseId(), "circuit-diagrams"); const units = service.listUnits(); - assert.equal(units.length, 1, "course should have a single unit"); + assert.equal(units.length, 2, "course should have two units"); const progress = service.getProgress(); - const activities = progress.units[0].activities; + const activities = progress.units[1].activities; const ids = activities.map((a) => a.id); - assert.include(ids, "build-bell"); - assert.include(ids, "display-circuit"); + assert.include(ids, "cat_circuit"); + assert.include(ids, "flat_circuit"); const exercises = activities.filter((a) => a.type === "exercise"); assert.equal(exercises.length, 2, "both tasks should become exercises"); From c35cc82702a39130309484414bc25f738f732def Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 15 Jul 2026 18:11:57 -0700 Subject: [PATCH 006/101] DO NOT MERGE env check logging --- source/vscode/src/learning/service.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 43fa83de4be..79182000c25 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { log } from "qsharp-lang"; import { getExerciseSources } from "qsharp-lang/katas-md"; import * as vscode from "vscode"; import { FullProgramConfig, getProgramForDocument } from "../programConfig.js"; @@ -629,8 +630,12 @@ export class LearningService { */ async runEnvironmentCheck(): Promise { const course = this.activeCourse; + log.info( + `[env-check] Starting for course "${course.id}" (kind=${course.kind})`, + ); if (course.kind !== "python-notebook") { + log.info(`[env-check] Q# course — skipping environment checks.`); const checks: EnvironmentCheckItem[] = [ check("course-kind", "Course type", "ok", { detail: "Q# course — runs on the built-in simulator.", @@ -646,6 +651,7 @@ export class LearningService { // Hard stop: environment management can't run on the Web. if (!env.supported) { + log.info(`[env-check] Web host — environment management unavailable.`); const checks: EnvironmentCheckItem[] = [ check("host", "Desktop VS Code", "fail", { detail: "Python courses require the desktop version of VS Code.", @@ -658,6 +664,7 @@ export class LearningService { // Resolve the course's working root (its source folder); the venv // lives here, beside the authored notebooks. if (!course.sourceDir) { + log.info(`[env-check] No sourceDir — cannot resolve course root.`); return this.assembleReport(course, [ check("course-folder", "Course folder", "fail", { detail: "This course has no source folder on disk.", @@ -665,11 +672,14 @@ export class LearningService { ]); } const courseRoot = vscode.Uri.parse(course.sourceDir); + log.info(`[env-check] Course root: ${courseRoot.fsPath}`); const checks: EnvironmentCheckItem[] = []; // 1. Required extensions (Python + Jupyter). + log.info(`[env-check] Checking extensions…`); const extMessage = await this.pythonRunner.ensureExtensions(); + log.info(`[env-check] Extensions: ${extMessage ?? "ok"}`); checks.push( check( "extensions", @@ -688,7 +698,9 @@ export class LearningService { ); // 2. Base Python interpreter (for bootstrapping the venv). + log.info(`[env-check] Checking interpreter…`); const interpreter = await env.ensureInterpreter(); + log.info(`[env-check] Interpreter: ${interpreter ?? "not found"}`); checks.push( check("interpreter", "Python interpreter", interpreter ? "ok" : "fail", { detail: interpreter ?? "No interpreter found.", @@ -700,8 +712,12 @@ export class LearningService { // 3. Tooling: uv (preferred) vs stdlib venv. Informational unless the // venv is missing AND the stdlib module is unavailable. + log.info(`[env-check] Checking for uv…`); const hasUv = await env.hasUv(); + log.info(`[env-check] uv available: ${hasUv}`); + log.info(`[env-check] Checking venv existence…`); const venvOk = await env.venvExists(courseRoot); + log.info(`[env-check] Venv exists: ${venvOk}`); if (hasUv) { checks.push( check("tooling", "Environment tooling", "ok", { @@ -710,9 +726,11 @@ export class LearningService { ); } else if (interpreter) { // Only probe the stdlib venv module when we'd actually need it. + log.info(`[env-check] Probing stdlib venv module…`); const venvModuleOk = venvOk ? true : await env.venvModuleSupported(interpreter); + log.info(`[env-check] venv module supported: ${venvModuleOk}`); checks.push( check( "tooling", @@ -744,7 +762,9 @@ export class LearningService { }), ); + log.info(`[env-check] Checking venv interpreter…`); const venvPython = venvOk ? await env.venvPython(courseRoot) : undefined; + log.info(`[env-check] Venv interpreter: ${venvPython ?? "n/a"}`); checks.push( check( "venv-interpreter", @@ -769,11 +789,15 @@ export class LearningService { // 5. Required packages import in the venv. if (venvPython) { // TODO (acasey): are these supposed to come from the course metadata or are these just a baseline for all courses? + log.info(`[env-check] Checking package imports…`); const report = await env.importsReport(courseRoot, [ "qdk", "qsharp_widgets", ]); const missing = report.filter((r) => !r.ok).map((r) => r.module); + log.info( + `[env-check] Import results: ${report.map((r) => `${r.module}=${r.ok ? "ok" : "fail"}`).join(", ")}`, + ); checks.push( check( "packages", @@ -796,6 +820,7 @@ export class LearningService { ), ); } else { + log.info(`[env-check] Skipping package imports — no venv interpreter.`); checks.push( check("packages", "Required packages", "skip", { detail: "No environment yet.", @@ -803,6 +828,7 @@ export class LearningService { ); } + log.info(`[env-check] Assembling report (${checks.length} checks).`); return this.assembleReport(course, checks); } From b29f5be9956630aa28bcaf1f4082cde847b911fc Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 16 Jul 2026 10:56:35 -0700 Subject: [PATCH 007/101] Handle lack of uv --- .../vscode/src/learning/python/environment.ts | 45 +++++++++++++------ source/vscode/src/learning/service.ts | 3 +- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 7dd271dcec7..eca4523327c 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -136,35 +136,54 @@ export class EnvironmentManager { } /** - * Sync the course environment using `uv sync`. This is the preferred - * method for courses that ship a `pyproject.toml`. It creates the `.venv` - * in the course's root and installs all declared dependencies - * in a single command. + * Sync the course environment from its `pyproject.toml`. Prefers `uv sync` + * when available; falls back to creating a venv with the stdlib `venv` + * module and installing with `pip install .`. * * @param courseRoot The course's source folder (where `pyproject.toml` * lives and where the `.venv` is created). + * @param pythonSpec Optional Python version specifier from course metadata + * (e.g. `">=3.11"`). Passed to {@link createVenv} in the fallback path. */ - async syncEnvironment(courseRoot: vscode.Uri): Promise { + async syncEnvironment( + courseRoot: vscode.Uri, + pythonSpec?: string, + ): Promise { if (!this.supported) { return; } - if (!(await this.uvAvailable())) { - throw new Error( - "`uv` is required to set up this course's Python environment but " + - "was not found on your PATH. Install it from https://docs.astral.sh/uv/", + if (await this.uvAvailable()) { + const code = await this.runShell( + "Sync course environment", + "uv", + ["sync", "--project", courseRoot.fsPath], + courseRoot, ); + if (code === 0) { + return; + } + log.warn( + `\`uv sync\` failed (exit ${code}); falling back to venv + pip.`, + ); + } + + // Fallback: create a venv and install from pyproject.toml using pip. + await this.createVenv(courseRoot, pythonSpec); + const python = await this.venvPython(courseRoot); + if (!python) { + throw new Error("Failed to create the virtual environment."); } const code = await this.runShell( - "Sync course environment", - "uv", - ["sync", "--project", courseRoot.fsPath], + "Install from pyproject.toml", + python, + ["-m", "pip", "install", "--disable-pip-version-check", "."], courseRoot, ); if (code !== 0) { throw new Error( - `\`uv sync\` failed (exit ${code}). Check the terminal output for details.`, + `\`pip install .\` failed (exit ${code}). Check the terminal output for details.`, ); } } diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 79182000c25..f1e0c2a223e 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -581,7 +581,8 @@ export class LearningService { ); if (hasPyproject) { // Preferred: `uv sync` resolves and installs from pyproject.toml. - await env.syncEnvironment(courseRoot); + // Falls back to venv + pip when uv is unavailable. + await env.syncEnvironment(courseRoot, course.environment?.python); } else { // Fallback: manual venv creation + pip install. await env.createVenv(courseRoot, course.environment?.python); From 33a5d0375b8ac7d29894fb1c0f6eccd4759d0671 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 16 Jul 2026 11:36:17 -0700 Subject: [PATCH 008/101] TODOs for qsharp-vscode.learningDoctor --- source/vscode/src/learning/panel.ts | 2 +- .../courses/circuit-diagrams-new/01-intro/intro.md | 2 +- .../qdk-learning/courses/circuit-diagrams-new/_check_env.py | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index b1e8d0d5a6a..16513bb2293 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -453,7 +453,7 @@ export class LessonPanelManager { enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true, - enableCommandUris: ["qsharp-vscode.learningCheckEnvironment"], + enableCommandUris: ["qsharp-vscode.learningCheckEnvironment"], // TODO (acasey): validate this localResourceRoots: [ vscode.Uri.joinPath(this.extensionUri, "out"), vscode.Uri.joinPath(this.extensionUri, "resources"), diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md index e236b08d7e0..3cf6c2b553b 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md @@ -21,4 +21,4 @@ This course runs in its own Python environment. If the notebook's kernel won't start, or the first cell reports a problem, set up and check your environment here first: -👉 [Check my environment](command:qsharp-vscode.learningDoctor) +👉 [Check my environment](command:qsharp-vscode.learningCheckEnvironment) diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py index 388e1250bdb..a75f8f9828b 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py @@ -140,6 +140,9 @@ def _find_venv_python(venv: Path) -> Path | None: return None +# TODO (acasey): these links don't seem to work in the notebook sandbox (either in input or output cells) +# Probably want to refer people to the lesson panel and/or the command palette +# TODO (acasey): the diagnostics don't appear to check whether the venv is active in the python notebook def _command_link(command_id: str, label: str) -> str: """Return an HTML link that invokes a VS Code command when clicked. From d292482b70bd8cf418110f952411cdcd580d33ca Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 16 Jul 2026 13:06:14 -0700 Subject: [PATCH 009/101] More TODOs --- source/vscode/src/learning/service.ts | 2 ++ source/vscode/src/learning/types.d.ts | 2 +- source/vscode/src/learning/webview/webview-client.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index f1e0c2a223e..172ef467096 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -608,12 +608,14 @@ export class LearningService { async applyEnvironmentCheckFix(fix: EnvironmentCheckFix): Promise { switch (fix.kind) { case "setup": + // TODO (acasey): should this be a command? await this.setupActiveEnvironment(); return; case "install-extensions": await this.pythonRunner.promptInstallExtensions(); return; case "select-kernel": + // TODO (acasey): is this ever offered? await vscode.commands.executeCommand("notebook.selectKernel"); return; case "docs": diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index ee5991effd9..757ea95a214 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -384,7 +384,7 @@ export interface EnvironmentCheckFix { * - `select-kernel`: re-select the course kernel for the notebook. * - `docs`: informational only; no action. */ - kind: "setup" | "install-extensions" | "select-kernel" | "docs"; + kind: "setup" | "install-extensions" | "select-kernel" | "docs"; // TODO (acasey): select-kernel appears to be unused } /** One diagnostic in an {@link EnvironmentCheckReport}. */ diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 4feea84b8ee..0a134e87225 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -78,7 +78,7 @@ function reducer(state: AppState, action: AppAction): AppState { action.direction === "next" ? { type: "text", - text: "🎉 You have completed all content!", + text: "🎉 You have completed all content!", // TODO (acasey): clear this on reset variant: "pass", } : { type: "text", text: "Already at the beginning." }; From dd4d157aa6e8e20631995c579e77ca47837b2e6a Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 16 Jul 2026 15:15:31 -0700 Subject: [PATCH 010/101] Handle exception in import check --- .../courses/circuit-diagrams-new/_check_env.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py index a75f8f9828b..5c0561440a8 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py @@ -92,7 +92,7 @@ def check(notebook_dir: str | Path | None = None) -> None: ) # --- Check 4: required packages --- - missing = [m for m in import_checks if importlib.util.find_spec(m) is None] + missing = [m for m in import_checks if not _can_import(m)] if missing: results.append( @@ -127,6 +127,14 @@ def check(notebook_dir: str | Path | None = None) -> None: ) +def _can_import(module_name: str) -> bool: + """Check whether *module_name* is importable without raising.""" + try: + return importlib.util.find_spec(module_name) is not None + except ModuleNotFoundError: + return False + + def _find_venv_python(venv: Path) -> Path | None: """Return the venv's Python interpreter path, or None if missing.""" candidates = [ From 23124b121a70c198198269caf8702b6d5b95b633 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 16 Jul 2026 16:36:49 -0700 Subject: [PATCH 011/101] Transfer TODOs from draft PR --- source/vscode/ai/qdk-learning.agent.md | 3 +++ source/vscode/src/learning/constants.ts | 1 + source/vscode/src/learning/courseProvider.ts | 2 ++ .../src/learning/dropInCourseProvider.ts | 5 ++++ .../src/learning/notebookCellStatusBar.ts | 24 +++++++++++++++++++ .../vscode/src/learning/python/environment.ts | 2 ++ 6 files changed, 37 insertions(+) diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index f6db18a5c97..397c0cb55af 100644 --- a/source/vscode/ai/qdk-learning.agent.md +++ b/source/vscode/ai/qdk-learning.agent.md @@ -4,6 +4,9 @@ description: "Learn quantum computing interactively in VS Code — guided lesson model: "Claude Haiku 4.5 (copilot)" --- +// TODO (acasey): review these changes +// Remove anything about trusted workspaces + # Quantum Development Kit Learning You are an agent that helps users navigate and interact with the QDK Learning feature in VS Code. Your role is to respond to chat prompts related to the active course, provide hints, explanations, and guidance. diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts index b7048ce1e3c..eb458a27bb2 100644 --- a/source/vscode/src/learning/constants.ts +++ b/source/vscode/src/learning/constants.ts @@ -24,6 +24,7 @@ export const LEARNING_WORKSPACE_DETECTED_CONTEXT = export const KATAS_COURSE_ID = "katas"; /** Per-course virtual environment folder (under the course working copy). */ +// TODO (acasey): is there a way we can make it search recursively during discovery? Sounds like there might be a workspace setting export const LEARNING_VENV_DIR = ".venv"; /** Tree view ID for the learning progress panel. */ diff --git a/source/vscode/src/learning/courseProvider.ts b/source/vscode/src/learning/courseProvider.ts index e80474c0534..c4b2d4078ec 100644 --- a/source/vscode/src/learning/courseProvider.ts +++ b/source/vscode/src/learning/courseProvider.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// TODO (acasey): consider merging into catalog.ts + import { loadKatasCourse } from "./catalog.js"; import { KATAS_COURSE_ID } from "./constants.js"; import type { CatalogCourse, CourseDescriptor } from "./types.js"; diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index bb3d7c733c9..43cbd48a741 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// TODO (acasey): consider merging into catalog.ts + import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { @@ -124,6 +126,7 @@ export class DropInCourseProvider implements CourseProvider { private async readManifest( dir: vscode.Uri, ): Promise { + // TODO (acasey): probably doesn't need to include readme.md - we know where that is const manifestUri = vscode.Uri.joinPath(dir, COURSE_MANIFEST_FILE); const text = await tryReadText(manifestUri); if (text === undefined) { @@ -278,6 +281,8 @@ export class DropInCourseProvider implements CourseProvider { } satisfies CatalogLesson); } + // TODO (acasey): might want multiple solutions + // Load exercise metadata from _exercises.json (optional). const exercisesJson = await tryReadText( vscode.Uri.joinPath(unitDir, "_exercises.json"), diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts index 1f7a0587730..f3e33e0d704 100644 --- a/source/vscode/src/learning/notebookCellStatusBar.ts +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { log } from "qsharp-lang"; import * as vscode from "vscode"; import type { LearningService } from "./service.js"; +// TODO (acasey): populate from _exercises.json +// TODO (acasey): might want _exercises.json to use cell IDs, rather than indices, if they're more stable /** * Pattern that identifies exercise/verification cells in python-notebook * courses. These cells import check functions from the per-unit `_unit` @@ -18,32 +21,53 @@ const exerciseCellPattern = /from\s+_unit\s+import\s+check/; export function createNotebookCellStatusBarProvider( service: LearningService, ): vscode.NotebookCellStatusBarItemProvider { + log.debug("createNotebookCellStatusBarProvider"); return { provideCellStatusBarItems( cell: vscode.NotebookCell, ): vscode.NotebookCellStatusBarItem[] { + log.debug("provideCellStatusBarItems called for cell %d", cell.index); + if (!service.initialized) { + log.debug("Skipping status bar: service not initialized"); return []; } const courseInfo = service.getActiveCourseInfo(); if (courseInfo.kind !== "python-notebook") { + log.debug( + "Skipping status bar: course kind is '%s', not 'python-notebook'", + courseInfo.kind, + ); return []; } // Only annotate code cells whose text contains a check import. if (cell.kind !== vscode.NotebookCellKind.Code) { + log.debug( + "Skipping status bar: cell %d is not a code cell", + cell.index, + ); return []; } const text = cell.document.getText(); if (!exerciseCellPattern.test(text)) { + log.debug( + "Skipping status bar: cell %d does not match exercise pattern", + cell.index, + ); return []; } // Use 1-based cell index as a definitive reference. const cellNumber = cell.index + 1; + log.debug( + "Adding 'Ask for a Hint' status bar item for cell %d", + cellNumber, + ); + const item = new vscode.NotebookCellStatusBarItem( "$(comment-discussion-sparkle) Ask for a Hint", vscode.NotebookCellStatusBarAlignment.Right, diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index eca4523327c..0820f57a801 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -114,6 +114,7 @@ export class EnvironmentManager { cwd, ); if (preflight !== 0) { + // TODO (acasey): use their python version number throw new Error( "This Python installation can't create virtual environments " + "(the `venv`/`ensurepip` modules are missing). On Debian/Ubuntu " + @@ -390,6 +391,7 @@ export class EnvironmentManager { * are typed here. */ private async pythonEnvironmentsApi(): Promise< + // TODO (acasey): consider naming this type | { getActiveEnvironmentPath?: (resource?: vscode.Uri) => { path?: string; From 9f3b3133995299141b3db349d9101438a00474e0 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 16 Jul 2026 17:25:01 -0700 Subject: [PATCH 012/101] First cut at using cell ID rather than cell index --- source/vscode/src/learning/commands.ts | 21 +++++++------ source/vscode/src/learning/index.ts | 9 ++++-- .../src/learning/notebookCellStatusBar.ts | 31 ++++++++++--------- source/vscode/src/learning/service.ts | 16 +++++----- source/vscode/src/learning/types.d.ts | 4 +-- .../01-intro/_exercises.json | 2 +- .../02-circuits/_exercises.json | 4 +-- 7 files changed, 48 insertions(+), 39 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index c96a6885888..3a5d85b0cb4 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -168,7 +168,7 @@ export function registerLearningCommands( vscode.commands.registerCommand( "qsharp-vscode.learningNotebookHint", - async (arg?: number | { cell: vscode.NotebookCell }) => { + async (arg?: string | { cell: vscode.NotebookCell }) => { if (!service.initialized) { return; } @@ -178,19 +178,22 @@ export function registerLearningCommands( return; } - // Resolve 1-based cell number from the argument: - // - number: passed directly from the cell status bar item + // Resolve cell ID from the argument: + // - string: passed directly from the cell status bar item // - { cell }: passed by VS Code when invoked from notebook/cell/title - let cellNumber: number | undefined; - if (typeof arg === "number") { - cellNumber = arg; + let cellId: string | undefined; + if (typeof arg === "string") { + cellId = arg; } else if (arg && "cell" in arg) { - cellNumber = arg.cell.index + 1; + const id = arg.cell.metadata?.id; + if (typeof id === "string") { + cellId = id; + } } // Navigate to the exercise so the service state matches. - if (cellNumber) { - await service.goToExerciseByCellIndex(cellNumber, "panel"); + if (cellId) { + await service.goToExerciseByCellId(cellId, "panel"); } await vscode.commands.executeCommand("workbench.action.chat.open", { diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index b8ec659dbb6..979f33479c4 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -50,10 +50,13 @@ export function initLearning( } for (const change of e.cellChanges) { if (change.executionSummary !== undefined) { - const cellIndex = change.cell.index + 1; - void learningService.goToExerciseByCellIndex(cellIndex, "panel"); + const cellId = change.cell.metadata?.id; + if (typeof cellId !== "string") { + continue; + } + void learningService.goToExerciseByCellId(cellId, "panel"); if (change.executionSummary.success) { - void learningService.markExerciseCompleteByCellIndex(cellIndex); + void learningService.markExerciseCompleteByCellId(cellId); } } } diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts index f3e33e0d704..83390051fb3 100644 --- a/source/vscode/src/learning/notebookCellStatusBar.ts +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -5,14 +5,12 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import type { LearningService } from "./service.js"; -// TODO (acasey): populate from _exercises.json -// TODO (acasey): might want _exercises.json to use cell IDs, rather than indices, if they're more stable /** * Pattern that identifies exercise/verification cells in python-notebook * courses. These cells import check functions from the per-unit `_unit` * module (e.g. `from _unit import check_value`). */ -const exerciseCellPattern = /from\s+_unit\s+import\s+check/; +// const exerciseCellPattern = /from\s+_unit\s+import\s+check/; /** * Registers a {@link vscode.NotebookCellStatusBarItemProvider} that adds a @@ -51,22 +49,27 @@ export function createNotebookCellStatusBarProvider( return []; } - const text = cell.document.getText(); - if (!exerciseCellPattern.test(text)) { + // TODO (acasey): populate from _exercises.json + // const text = cell.document.getText(); + // if (!exerciseCellPattern.test(text)) { + // log.debug( + // "Skipping status bar: cell %d does not match exercise pattern", + // cell.index, + // ); + // return []; + // } + + // Use the cell's stable ID from notebook metadata. + const cellId = cell.metadata?.id; + if (typeof cellId !== "string") { log.debug( - "Skipping status bar: cell %d does not match exercise pattern", + "Skipping status bar: cell %d has no metadata.id", cell.index, ); return []; } - // Use 1-based cell index as a definitive reference. - const cellNumber = cell.index + 1; - - log.debug( - "Adding 'Ask for a Hint' status bar item for cell %d", - cellNumber, - ); + log.debug("Adding 'Ask for a Hint' status bar item for cell %s", cellId); const item = new vscode.NotebookCellStatusBarItem( "$(comment-discussion-sparkle) Ask for a Hint", @@ -75,7 +78,7 @@ export function createNotebookCellStatusBarProvider( item.command = { title: "Ask for a Hint", command: "qsharp-vscode.learningNotebookHint", - arguments: [cellNumber], + arguments: [cellId], }; item.tooltip = "Open Copilot Chat for a hint on this exercise"; return [item]; diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 172ef467096..633412b00b9 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -457,15 +457,15 @@ export class LearningService { } /** - * Navigate to the exercise activity whose `cellIndex` matches the given - * 1-based cell number. Returns `true` if the position was updated. + * Navigate to the exercise activity whose `cellId` matches the given + * notebook cell ID. Returns `true` if the position was updated. * Only meaningful for python-notebook courses. * * Updates the position silently — does **not** fire the state-change * event, so the lesson panel won't pop up or rearrange the editor layout. */ - async goToExerciseByCellIndex( - cellIndex: number, + async goToExerciseByCellId( + cellId: string, source?: TelemetrySource, ): Promise { if (this.activeCourse.kind !== "python-notebook") { @@ -473,7 +473,7 @@ export class LearningService { } const unit = this.findUnit(this.position.unitId); const exercise = unit.notebookExercises?.find( - (e) => e.cellIndex === cellIndex, + (e) => e.cellId === cellId, ); if (!exercise) { return false; @@ -497,17 +497,17 @@ export class LearningService { } /** - * Mark the exercise activity at the given 1-based cell index as complete. + * Mark the exercise activity with the given cell ID as complete. * Returns `true` if the exercise was found and marked (or already complete). * Fires the state-change event so the treeview updates. */ - async markExerciseCompleteByCellIndex(cellIndex: number): Promise { + async markExerciseCompleteByCellId(cellId: string): Promise { if (this.activeCourse.kind !== "python-notebook") { return false; } const unit = this.findUnit(this.position.unitId); const exercise = unit.notebookExercises?.find( - (e) => e.cellIndex === cellIndex, + (e) => e.cellId === cellId, ); if (!exercise) { return false; diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index 757ea95a214..f01689a1122 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -277,8 +277,8 @@ export interface NotebookExerciseInfo { hints: string[]; solution: string; solutionExplanation: string; - /** 1-based cell index in the notebook where this exercise lives. */ - cellIndex?: number; + /** Stable cell ID (from the notebook's cell metadata) for this exercise. */ + cellId?: string; } export interface CatalogUnit { diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json index 5a672062693..1410315603e 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json @@ -2,7 +2,7 @@ "exercises": [ { "id": "forty_two", - "cellIndex": 7, + "cellId": "d9a84106", "title": "Your first Q# expression", "description": "Implement the forty_two() function so it returns 42.", "hints": [ diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json index 7f473205f65..303186a1826 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json @@ -2,7 +2,7 @@ "exercises": [ { "id": "cat_circuit", - "cellIndex": 22, + "cellId": "12d649d7", "title": "Render with operation=", "description": "Implement cat_circuit() to return a circuit built with circuit() using the operation= parameter for PrepareCatState.", "hints": [ @@ -14,7 +14,7 @@ }, { "id": "flat_circuit", - "cellIndex": 24, + "cellId": "c8d8aca1", "title": "Flatten a grouped circuit", "description": "Implement flat_circuit() to return a circuit for GHZ(3) with grouping disabled so each gate is shown individually.", "hints": [ From cb2d16283f5ea8f68f83d934a175ae692049dd8b Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 16 Jul 2026 17:35:08 -0700 Subject: [PATCH 013/101] Only offer hints for exercise cells --- source/vscode/ai/qdk-learning.agent.md | 2 +- .../src/learning/dropInCourseProvider.ts | 3 ++- .../src/learning/notebookCellStatusBar.ts | 26 ++++++------------ source/vscode/src/learning/service.ts | 27 ++++++++++++++----- source/vscode/src/learning/types.d.ts | 2 +- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index 397c0cb55af..5c204af08de 100644 --- a/source/vscode/ai/qdk-learning.agent.md +++ b/source/vscode/ai/qdk-learning.agent.md @@ -5,7 +5,7 @@ model: "Claude Haiku 4.5 (copilot)" --- // TODO (acasey): review these changes -// Remove anything about trusted workspaces +// Remove anything about trusted workspaces # Quantum Development Kit Learning diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 43cbd48a741..0101b899e69 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -299,7 +299,8 @@ export class DropInCourseProvider implements CourseProvider { (e): e is NotebookExerciseInfo => !!e && typeof e === "object" && - typeof (e as NotebookExerciseInfo).id === "string", + typeof (e as NotebookExerciseInfo).id === "string" && + typeof (e as NotebookExerciseInfo).cellId === "string", ); } } catch (e) { diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts index 83390051fb3..b3c7d1ad7a9 100644 --- a/source/vscode/src/learning/notebookCellStatusBar.ts +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -5,13 +5,6 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import type { LearningService } from "./service.js"; -/** - * Pattern that identifies exercise/verification cells in python-notebook - * courses. These cells import check functions from the per-unit `_unit` - * module (e.g. `from _unit import check_value`). - */ -// const exerciseCellPattern = /from\s+_unit\s+import\s+check/; - /** * Registers a {@link vscode.NotebookCellStatusBarItemProvider} that adds a * "Ask for a Hint" button to exercise code cells in python-notebook courses. @@ -40,7 +33,7 @@ export function createNotebookCellStatusBarProvider( return []; } - // Only annotate code cells whose text contains a check import. + // Only annotate code cells that are exercises. if (cell.kind !== vscode.NotebookCellKind.Code) { log.debug( "Skipping status bar: cell %d is not a code cell", @@ -49,16 +42,6 @@ export function createNotebookCellStatusBarProvider( return []; } - // TODO (acasey): populate from _exercises.json - // const text = cell.document.getText(); - // if (!exerciseCellPattern.test(text)) { - // log.debug( - // "Skipping status bar: cell %d does not match exercise pattern", - // cell.index, - // ); - // return []; - // } - // Use the cell's stable ID from notebook metadata. const cellId = cell.metadata?.id; if (typeof cellId !== "string") { @@ -69,6 +52,13 @@ export function createNotebookCellStatusBarProvider( return []; } + // Only show the hint button for cells that are exercises. + const exerciseCellIds = service.getExerciseCellIds(); + if (!exerciseCellIds.has(cellId)) { + log.debug("Skipping status bar: cell %s is not an exercise", cellId); + return []; + } + log.debug("Adding 'Ask for a Hint' status bar item for cell %s", cellId); const item = new vscode.NotebookCellStatusBarItem( diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 633412b00b9..c552377fb53 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -472,9 +472,7 @@ export class LearningService { return false; } const unit = this.findUnit(this.position.unitId); - const exercise = unit.notebookExercises?.find( - (e) => e.cellId === cellId, - ); + const exercise = unit.notebookExercises?.find((e) => e.cellId === cellId); if (!exercise) { return false; } @@ -506,9 +504,7 @@ export class LearningService { return false; } const unit = this.findUnit(this.position.unitId); - const exercise = unit.notebookExercises?.find( - (e) => e.cellId === cellId, - ); + const exercise = unit.notebookExercises?.find((e) => e.cellId === cellId); if (!exercise) { return false; } @@ -526,6 +522,25 @@ export class LearningService { return true; } + /** + * Returns the set of cell IDs that correspond to exercises in the + * current unit. Empty if the course isn't a python-notebook course or + * there are no exercises. + */ + getExerciseCellIds(): Set { + if (this.activeCourse.kind !== "python-notebook") { + return new Set(); + } + const unit = this.findUnit(this.position.unitId); + const ids = new Set(); + if (unit.notebookExercises) { + for (const ex of unit.notebookExercises) { + ids.add(ex.cellId); + } + } + return ids; + } + /** Enumerate all available courses (loaded or not). */ async getCourses(): Promise { return this.requireWorkspace().registry.listCourses(); diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index f01689a1122..08713d0b925 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -278,7 +278,7 @@ export interface NotebookExerciseInfo { solution: string; solutionExplanation: string; /** Stable cell ID (from the notebook's cell metadata) for this exercise. */ - cellId?: string; + cellId: string; } export interface CatalogUnit { From 6ee65308c4ba6a42789180b3b8a75550ea4c6f23 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 17 Jul 2026 09:29:31 -0700 Subject: [PATCH 014/101] More TODOs --- source/vscode/src/learning/commands.ts | 1 + .../qdk-learning/courses/circuit-diagrams-new/_course_lib.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 3a5d85b0cb4..e08b9b4dfdb 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -309,6 +309,7 @@ async function runEnvironmentCheckCommand( service: LearningService, node?: LearningProgressNode, ): Promise { + // TODO (acasey): don't allow overlapping runs if (!service.initialized) { const ok = await service.tryInitialize({ createIfMissing: true }); if (!ok) { diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py index fe3ab2d19dc..900462379e7 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py @@ -183,6 +183,7 @@ def complete_unit(required_exercises: list[str] | None = None) -> None: missing = [e for e in required_exercises if e not in _passed] if missing: names = ", ".join(f"`{e}`" for e in missing) + # TODO (acasey): pretty report raise AssertionError( f"Not all exercises are complete. Missing: {names}. " "Run the exercise cells above first." @@ -193,6 +194,7 @@ def complete_unit(required_exercises: list[str] | None = None) -> None: marker = Path(".qdk-unit-complete") marker.write_text(f"{unit_id}\n") + # TODO (acasey): dark mode display( HTML( '
    Quantum Katas (_kaˑta_ | kah-tuh — Japanese for "form", a pattern of learning and practicing new skills) are self-paced, AI-assisted tutorials for quantum computing and Q# programming. Each tutorial includes relevant theory and interactive hands-on exercises designed to test knowledge. -The tools refer to each unit of a course as a "unit." Each unit contains ordered activities (lessons, examples, exercises). +The tools refer to each kata as a "unit". In other courses, there are no katas, simply units. Each unit contains ordered activities (lessons, examples, exercises). **Tool naming:** All learning tools share the `qdk-learning-` prefix. This document uses short names (e.g. `show` for `qdk-learning-show`). @@ -44,18 +41,17 @@ Mention that they can chat with you at any time for hints, explanations, or guid Multiple courses may be available. The active course is reported by `get-state` (the `course` field) and is the context for all activity, run, and check operations. The **Quantum Katas** is the default course. -| Intent | Tool | Notes | -| ------------------------------------- | --------------- | ------------------------------------------------------------------------- | -| "What courses are available?" | `list-courses` | Returns the available courses and the active course id. | -| "Switch to …" / "Open the … course" | `switch-course` | Pass the `courseId`. Switching changes the active course and position. | -| "Tell me about this course" | `course-info` | Returns the course descriptor and README (defaults to the active course). | -| "Diagnose" / "Set up the environment" | `doctor` | Runs environment diagnostics for the active course (Python courses). | +| Intent | Tool | Notes | +| ------------------------------------- | ------------------- | ------------------------------------------------------------------------- | +| "What courses are available?" | `list-courses` | Returns the available courses and the active course id. | +| "Switch to …" / "Open the … course" | `switch-course` | Pass the `courseId`. Switching changes the active course and position. | +| "Tell me about this course" | `course-info` | Returns the course descriptor and README (defaults to the active course). | +| "Diagnose" / "Set up the environment" | `check-environment` | Runs environment diagnostics for the active course (Python courses). | **Handling guidance:** - When the user asks to change courses, call `list-courses` first if you're unsure of the exact `courseId`, match the user's request to a course, then call `switch-course`. After switching, call `show` to surface the new course's current activity and briefly tell the user where they landed. -- Drop-in courses run author-provided code and only load in a **trusted** workspace. If a drop-in course doesn't appear or won't run, the workspace may be in Restricted Mode — suggest trusting the workspace. -- Python notebook courses use a per-course environment. If running or checking a task reports environment or kernel problems, call `doctor` to diagnose; it reports which checks fail and whether a one-click setup can fix them. Q# courses need no environment and always pass `doctor`. +- Python notebook courses use a per-course environment. If running or checking a task reports environment or kernel problems, call `check-environment` to diagnose; it reports which checks fail and whether a one-click setup can fix them. The katas need no environment and always pass `check-environment`. - Don't switch courses unless the user clearly asks. Panel and tree actions can also switch courses without involving you, so always call `get-state` to learn the current course before answering. ## Tone From aa28d8ef852812ef2182666c38f3b7576063ef73 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 17 Jul 2026 13:51:11 -0700 Subject: [PATCH 016/101] TODOs and tweaks from diff review --- source/vscode/src/extension.ts | 1 + source/vscode/src/gh-copilot/learningTools.ts | 6 ++++-- source/vscode/src/learning/commands.ts | 8 ++++++++ source/vscode/src/learning/courseProvider.ts | 2 ++ .../vscode/src/learning/dropInCourseProvider.ts | 15 ++++++++------- source/vscode/src/learning/index.ts | 1 + .../vscode/src/learning/notebookCellStatusBar.ts | 1 + source/vscode/src/learning/panel.ts | 6 ++++++ source/vscode/src/learning/progressTreeView.ts | 3 +++ source/vscode/src/learning/python/environment.ts | 5 +++++ source/vscode/src/learning/python/pythonRunner.ts | 3 +++ source/vscode/src/learning/service.ts | 13 ++++++++++--- 12 files changed, 52 insertions(+), 12 deletions(-) diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index 8ba91954570..b05ba7be2c0 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -108,6 +108,7 @@ export async function activate( if (context.extensionMode === vscode.ExtensionMode.Test) { // Test-only seam: expose the learning service so integration tests can // drive multi-course flows without UI automation. + // TODO (acasey): seems kind of suspicious that this would be the only test suite that needs this api.learning = learningService; } } diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 82e3752c27e..2c89a197e18 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -27,7 +27,7 @@ import { CopilotToolError } from "./types.js"; */ export interface SerializedLearningState { /** The currently-active course. */ - course: { id: string; title: string; kind: string }; + course: Pick; position: CurrentActivity; progress: { totalActivities: number; @@ -174,6 +174,7 @@ export class LearningTools { descriptor: CourseDescriptor | undefined; readme?: string; }> { + // TODO (acasey): drop readme? await this.ensureInitialized(); return this.invoke(async () => { const courseId = input?.courseId ?? this.service.getActiveCourseId(); @@ -200,6 +201,7 @@ export class LearningTools { * environment setup is available). */ async checkEnvironment(): Promise { + // TODO (acasey): ensure only one can run at a time await this.ensureInitialized(); return this.invoke(() => this.service.runEnvironmentCheck()); } @@ -214,7 +216,7 @@ export class LearningTools { const uri = this.getCurrentFileUri(); if (this.service.getActiveCourseInfo().kind === "python-notebook") { return { - code: "", + code: "", // TODO (acasey): can/should we get the code in the active cell? filePath: uri.fsPath, }; } diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index e08b9b4dfdb..30bfdfa9fec 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -56,6 +56,8 @@ export function registerLearningCommands( }, ), + // In spite of the name, this is used to start the learning experience + // (typically, via a button on the Welcome screen). vscode.commands.registerCommand( "qsharp-vscode.learningContinue", async () => { @@ -96,6 +98,7 @@ export function registerLearningCommands( node.kind === "activity" && node.activity.type === "exercise" ) { + // TODO (acasey): is there a way to focus on a particular cell? (maybe goToExerciseByCellId?) const notebookUri = service.getCurrentCodeFileUri(); if (notebookUri) { await vscode.commands.executeCommand( @@ -119,6 +122,7 @@ export function registerLearningCommands( async (node?: LearningProgressNode) => { const courseId = await resolveCourseId(service, node); if (!courseId) { + // TODO (acasey): at least log this return; } await service.switchCourse(courseId, "tree"); @@ -131,6 +135,7 @@ export function registerLearningCommands( async (node?: LearningProgressNode) => { const courseId = await resolveCourseId(service, node); if (!courseId) { + // TODO (acasey): at least log this return; } await showCourseInfo(service, courseId); @@ -247,6 +252,7 @@ function nodeToLocation( * Resolve a target course id from a tree node, or prompt the user with a * quick pick when invoked without one (e.g. from the command palette). */ +// TODO (acasey): is this actually in the command palette? If not, do we need a picker? async function resolveCourseId( service: LearningService, node?: LearningProgressNode, @@ -285,6 +291,7 @@ async function showCourseInfo( const courses = await service.getCourses(); const descriptor = courses.find((c) => c.id === courseId); if (!descriptor) { + // TODO (acasey): log return; } if (descriptor.readmePath) { @@ -357,6 +364,7 @@ async function runEnvironmentCheckCommand( ].join("\n"); const actions = report.fixes.map((r) => r.label); + // TODO (acasey): this is ugly and unthemed - can we do better? const choice = await vscode.window.showInformationMessage( body, { modal: true }, diff --git a/source/vscode/src/learning/courseProvider.ts b/source/vscode/src/learning/courseProvider.ts index c4b2d4078ec..eaffee4833d 100644 --- a/source/vscode/src/learning/courseProvider.ts +++ b/source/vscode/src/learning/courseProvider.ts @@ -44,6 +44,7 @@ export class CourseRegistry { continue; } for (const descriptor of descriptors) { + // TODO (acasey): what is this guarding against? Multiple providers offering the same course? One provider offering multiple courses with the same ID? if (seen.has(descriptor.id)) { continue; } @@ -75,6 +76,7 @@ export class CourseRegistry { } } +// TODO (acasey): separate file (if it survives) /** Provider for the built-in Quantum Katas course. */ export class KatasProvider implements CourseProvider { readonly id = "katas-provider"; diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 0101b899e69..9248f95a8e6 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -167,6 +167,7 @@ export class DropInCourseProvider implements CourseProvider { shortDescription: manifestString(loc.manifest.shortDescription), environment: manifestEnvironment(loc.manifest.environment), }; + // TODO (acasey): well-known location (or eliminate) const readme = manifestString(loc.manifest.readme); if (readme) { const readmeUri = vscode.Uri.joinPath(loc.dir, readme); @@ -247,9 +248,9 @@ export class DropInCourseProvider implements CourseProvider { (e) => e.type === vscode.FileType.File && e.name.toLowerCase().endsWith(".ipynb") && - !e.name.toLowerCase().endsWith(".workbook.ipynb"), + !e.name.toLowerCase().endsWith(".workbook.ipynb"), // TODO (acasey): constant for .workbook ) - .sort((a, b) => a.name.localeCompare(b.name))[0]; + .sort((a, b) => a.name.localeCompare(b.name))[0]; // TODO (acasey): log finding multiple if (!notebookEntry) { log.warn( `Unit "${unit.id}" has no .ipynb notebook in ${unitDir.fsPath}.`, @@ -281,8 +282,6 @@ export class DropInCourseProvider implements CourseProvider { } satisfies CatalogLesson); } - // TODO (acasey): might want multiple solutions - // Load exercise metadata from _exercises.json (optional). const exercisesJson = await tryReadText( vscode.Uri.joinPath(unitDir, "_exercises.json"), @@ -305,7 +304,7 @@ export class DropInCourseProvider implements CourseProvider { } } catch (e) { log.warn( - `Failed to parse _exercises.json in unit "${unit.id}": ${String(e)}`, + `Failed to parse _exercises.json in unit "${unit.id}": ${String(e)}`, // TODO (acasey): Include course name? ); } } @@ -322,7 +321,7 @@ export class DropInCourseProvider implements CourseProvider { placeholderCode: "", sourceIds: [], hints: ex.hints, - solutionCodes: ex.solution ? [ex.solution] : [], + solutionCodes: ex.solution ? [ex.solution] : [], // TODO (acasey): might want multiple solutions in python courses too solutionExplanation: ex.solutionExplanation ?? "", } satisfies CatalogExercise); } @@ -412,7 +411,7 @@ async function readDirSafe( async function tryReadText(uri: vscode.Uri): Promise { try { const bytes = await vscode.workspace.fs.readFile(uri); - return new TextDecoder().decode(bytes); + return new TextDecoder().decode(bytes); // TODO (acasey): encoding? } catch { return undefined; } @@ -429,6 +428,8 @@ async function uriExists(uri: vscode.Uri): Promise { // ─── Text helpers ─── +// TODO (acasey): do we need this level of support? Can we just insist on metadata? + /** First markdown ATX heading (`# Title`) in the text, if any. */ function firstHeading(markdown: string): string | undefined { const match = markdown.match(/^#{1,6}\s+(.+?)\s*$/m); diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index 979f33479c4..8c42e49eb37 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -39,6 +39,7 @@ export function initLearning( ); context.subscriptions.push( vscode.workspace.onDidChangeNotebookDocument((e) => { + // TODO (acasey): auto-save? // When a cell finishes executing (executionSummary changes), check // if it corresponds to an exercise in the active python-notebook // course and update focus. If execution succeeded, mark complete. diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts index b3c7d1ad7a9..24673427172 100644 --- a/source/vscode/src/learning/notebookCellStatusBar.ts +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -12,6 +12,7 @@ import type { LearningService } from "./service.js"; export function createNotebookCellStatusBarProvider( service: LearningService, ): vscode.NotebookCellStatusBarItemProvider { + // TODO (acasey): clean up logging log.debug("createNotebookCellStatusBarProvider"); return { provideCellStatusBarItems( diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index 16513bb2293..dcb153c6e2b 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -327,6 +327,7 @@ export class LessonPanelManager { } if (msg.command === "browseCourses") { + // TODO (acasey): was this supposed to be list courses? await vscode.commands.executeCommand( "qsharp-vscode.learningSwitchCourse", ); @@ -346,6 +347,7 @@ export class LessonPanelManager { try { switch (action) { case "next": { + // Activity-level navigation doesn't make sense in python notebooks const result = this.isPythonNotebook ? await this.service.nextUnit("panel") : await this.service.next("panel"); @@ -353,6 +355,7 @@ export class LessonPanelManager { break; } case "back": { + // Activity-level navigation doesn't make sense in python notebooks const result = this.isPythonNotebook ? await this.service.previousUnit("panel") : await this.service.previous("panel"); @@ -425,6 +428,7 @@ export class LessonPanelManager { if (!notebookUri) { return; } + // TODO (acasey): we can get rid of columns if we drop the web view panel // Set a two-column layout: lesson panel left, notebook right. await vscode.commands.executeCommand("vscode.setEditorLayout", { orientation: 0, @@ -501,6 +505,8 @@ export class LessonPanelManager { private async checkSolutionAndSendResult( source?: TelemetrySource, ): Promise { + // TODO (acasey): why isn't this state okay? + // TODO (acasey): update checkSolution or other callers const { result } = await this.service.checkSolution(source); this.sendMessage({ command: "result", diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts index ce4c3dc92ff..01a33602b08 100644 --- a/source/vscode/src/learning/progressTreeView.ts +++ b/source/vscode/src/learning/progressTreeView.ts @@ -85,6 +85,7 @@ class LearningProgressTreeProvider implements vscode.TreeDataProvider a.id !== "intro") diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 0820f57a801..292898947d5 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -5,6 +5,8 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { LEARNING_VENV_DIR } from "../constants.js"; +// TODO (acasey): rewrite in terms of VS Code API + /** * Manages per-course Python environments for `python-notebook` courses. * @@ -50,6 +52,7 @@ export class EnvironmentManager { return undefined; } const fromExtension = await this.activeInterpreterPath(); + // TODO (acasey): confirm availability of python3? return fromExtension ?? "python3"; } @@ -261,6 +264,8 @@ export class EnvironmentManager { return; } + // TODO (acasey): confirm this is working (or could work) + // Primary, stable path: point the Python extension at the venv // interpreter for this notebook resource. const python = await this.venvPython(courseRoot); diff --git a/source/vscode/src/learning/python/pythonRunner.ts b/source/vscode/src/learning/python/pythonRunner.ts index 932259e6adf..b31aead912c 100644 --- a/source/vscode/src/learning/python/pythonRunner.ts +++ b/source/vscode/src/learning/python/pythonRunner.ts @@ -5,6 +5,8 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import type { CatalogCourse } from "../types.js"; +// TODO (acasey): rename this + /** * Manages `python-notebook` course files. All Jupyter/notebook execution * is handled by VS Code's native notebook UI — this class only handles @@ -47,6 +49,7 @@ export class PythonCourseRunner { if (vscode.env.uiKind === vscode.UIKind.Web) { return; } + // TODO (acasey): share code with ensureExtensions const required: { id: string; name: string }[] = [ { id: "ms-python.python", name: "Python" }, { id: "ms-toolsai.jupyter", name: "Jupyter" }, diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index c552377fb53..4b53da4a389 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -286,6 +286,7 @@ export class LearningService { * Other course kinds fall through to {@link getState}. */ getStateForPanel(): LearningState { + // TODO (acasey): might be moot if exercise-level navigation works? if (this.activeCourse.kind !== "python-notebook") { return this.getState(); } @@ -506,6 +507,7 @@ export class LearningService { const unit = this.findUnit(this.position.unitId); const exercise = unit.notebookExercises?.find((e) => e.cellId === cellId); if (!exercise) { + // TODO (acasey): log unknown exercise return false; } const location: ActivityLocation = { @@ -548,11 +550,12 @@ export class LearningService { /** The id of the currently-active course. */ getActiveCourseId(): string { + // Don't do the extra work that this.activeCourse.id would require return this.requireWorkspace().progressData.position.courseId; } /** Compact info about the active course for serialization to chat tools. */ - getActiveCourseInfo(): { id: string; title: string; kind: CourseKind } { + getActiveCourseInfo(): Pick { const course = this.activeCourse; return { id: course.id, title: course.title, kind: course.kind }; } @@ -669,7 +672,7 @@ export class LearningService { // Hard stop: environment management can't run on the Web. if (!env.supported) { - log.info(`[env-check] Web host — environment management unavailable.`); + log.info(`[env-check] Environment management unavailable in current editor.`); const checks: EnvironmentCheckItem[] = [ check("host", "Desktop VS Code", "fail", { detail: "Python courses require the desktop version of VS Code.", @@ -701,6 +704,7 @@ export class LearningService { checks.push( check( "extensions", + // TODO (acasey): Shouldn't need to keep these in sync with ensureExtensions "Python & Jupyter extensions", extMessage ? "fail" : "ok", { @@ -724,6 +728,7 @@ export class LearningService { detail: interpreter ?? "No interpreter found.", hint: interpreter ? undefined + // TODO (acasey): how did we pick 3.9? : "Install Python (3.9+) and select an interpreter via the Python extension.", }), ); @@ -756,10 +761,12 @@ export class LearningService { venvModuleOk ? "warn" : "fail", { detail: venvModuleOk + // TODO (acasey): do we want to recommend uv? ? "Using the standard-library `venv` (install `uv` for faster setup)." : "The `venv`/`ensurepip` modules are missing from this Python.", hint: venvModuleOk ? undefined + // TODO (acasey): can we determine the actual version number? : "On Debian/Ubuntu install them with `sudo apt install python3-venv` " + "(matching your Python version, e.g. `python3.12-venv`).", }, @@ -1490,7 +1497,7 @@ export class LearningService { const descriptors = await registry.listCourses(); for (const descriptor of descriptors) { try { - // TODO (acasey): do this lazily? + // TODO (acasey): other code (and Mine) mentioned doing this lazily const course = await registry.loadCourse(descriptor.id); courses.set(course.id, course); } catch { From 073f94c65fd8d57209b62da7958e06683c45e1e7 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 20 Jul 2026 13:29:33 -0700 Subject: [PATCH 017/101] More TODOs --- source/vscode/src/learning/courseProvider.ts | 4 ++++ source/vscode/src/learning/service.ts | 19 ++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/source/vscode/src/learning/courseProvider.ts b/source/vscode/src/learning/courseProvider.ts index eaffee4833d..cbb4be4ae0f 100644 --- a/source/vscode/src/learning/courseProvider.ts +++ b/source/vscode/src/learning/courseProvider.ts @@ -7,6 +7,10 @@ import { loadKatasCourse } from "./catalog.js"; import { KATAS_COURSE_ID } from "./constants.js"; import type { CatalogCourse, CourseDescriptor } from "./types.js"; +// TODO (acasey): there are a bunch of places where we disable things in notebook courses - +// it seems like we should have a property on the interface instead of using the course kind string +// e.g. `this.activeCourse.kind === "python-notebook"` + /** * A source of learning courses. Implementations know how to enumerate the * courses they provide and how to fully load a course by id. diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 4b53da4a389..c96dfceff48 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -672,7 +672,9 @@ export class LearningService { // Hard stop: environment management can't run on the Web. if (!env.supported) { - log.info(`[env-check] Environment management unavailable in current editor.`); + log.info( + `[env-check] Environment management unavailable in current editor.`, + ); const checks: EnvironmentCheckItem[] = [ check("host", "Desktop VS Code", "fail", { detail: "Python courses require the desktop version of VS Code.", @@ -728,8 +730,8 @@ export class LearningService { detail: interpreter ?? "No interpreter found.", hint: interpreter ? undefined - // TODO (acasey): how did we pick 3.9? - : "Install Python (3.9+) and select an interpreter via the Python extension.", + : // TODO (acasey): how did we pick 3.9? + "Install Python (3.9+) and select an interpreter via the Python extension.", }), ); @@ -761,13 +763,13 @@ export class LearningService { venvModuleOk ? "warn" : "fail", { detail: venvModuleOk - // TODO (acasey): do we want to recommend uv? - ? "Using the standard-library `venv` (install `uv` for faster setup)." + ? // TODO (acasey): do we want to recommend uv? + "Using the standard-library `venv` (install `uv` for faster setup)." : "The `venv`/`ensurepip` modules are missing from this Python.", hint: venvModuleOk ? undefined - // TODO (acasey): can we determine the actual version number? - : "On Debian/Ubuntu install them with `sudo apt install python3-venv` " + + : // TODO (acasey): can we determine the actual version number? + "On Debian/Ubuntu install them with `sudo apt install python3-venv` " + "(matching your Python version, e.g. `python3.12-venv`).", }, ), @@ -2071,6 +2073,9 @@ export class LearningService { this._onDidChangeProgress.fire(this._lastSnapshot); } + // TODO (acasey): consider having a state.json file for the whole course instead of a bunch of little sentinel files + // This may require more coordination than is comfortable for content authors. + /** * Start watching for `.qdk-unit-complete` sentinel files in the active * python-notebook course folder. When the notebook's `complete_unit()` writes this From 793bcb8b6d7e70ff57771f2476501c60ce963ef1 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 22 Jul 2026 17:03:27 -0700 Subject: [PATCH 018/101] First cut at consuming Python Environments API --- package-lock.json | 12 ++++ package.json | 1 + .../vscode/src/learning/python/environment.ts | 63 +++++++------------ 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index a047cd7db12..b700bf4e980 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@vscode/debugprotocol": "^1.68.0", "@vscode/extension-telemetry": "0.8.5", "@vscode/markdown-it-katex": "^1.0.0", + "@vscode/python-environments": "^1.0.0", "@vscode/test-electron": "^2.5.2", "@vscode/test-web": "^0.0.81", "3dmol": "^2.5.4", @@ -2224,6 +2225,17 @@ "katex": "^0.16.4" } }, + "node_modules/@vscode/python-environments": { + "version": "1.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/python-environments/-/python-environments-1.0.0.tgz", + "integrity": "sha1-kkByaKa5E7P1CiKm4ruZFTQPubc=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.21.1", + "vscode": "^1.110.0" + } + }, "node_modules/@vscode/test-electron": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", diff --git a/package.json b/package.json index 4ce70f1d0c1..1bebe0c5ff7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@vscode/debugprotocol": "^1.68.0", "@vscode/extension-telemetry": "0.8.5", "@vscode/markdown-it-katex": "^1.0.0", + "@vscode/python-environments": "^1.0.0", "@vscode/test-electron": "^2.5.2", "@vscode/test-web": "^0.0.81", "3dmol": "^2.5.4", diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 292898947d5..5ef392cc64f 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -2,6 +2,8 @@ // Licensed under the MIT License. import { log } from "qsharp-lang"; +import type { PythonEnvironmentApi } from "@vscode/python-environments"; +import { PythonEnvironments } from "@vscode/python-environments"; import * as vscode from "vscode"; import { LEARNING_VENV_DIR } from "../constants.js"; @@ -24,6 +26,8 @@ export class EnvironmentManager { private readonly controllers = new Map(); /** Cached result of probing for `uv` on the PATH. */ private _uvAvailable: boolean | undefined; + /** Cached Python Environments extension API (only set on success). */ + private _pythonEnvApi: PythonEnvironmentApi | undefined; dispose(): void { for (const controller of this.controllers.values()) { @@ -391,54 +395,34 @@ export class EnvironmentManager { // ─── Private: swappable OS interaction ─── /** - * The Python extension's stable `environments` API, or `undefined` when - * the extension is unavailable. Only the documented, non-proposed members - * are typed here. + * The Python Environments extension API, or `undefined` when the + * extension is unavailable. A successful lookup is cached; failures + * are retried so the extension can be installed mid-session. */ private async pythonEnvironmentsApi(): Promise< - // TODO (acasey): consider naming this type - | { - getActiveEnvironmentPath?: (resource?: vscode.Uri) => { - path?: string; - }; - updateActiveEnvironmentPath?: ( - environment: string, - resource?: vscode.Uri, - ) => Thenable; - } - | undefined + PythonEnvironmentApi | undefined > { - const ext = vscode.extensions.getExtension("ms-python.python"); - if (!ext) { - return undefined; + if (this._pythonEnvApi) { + return this._pythonEnvApi; } try { - const api = (await ext.activate()) as { - environments?: { - getActiveEnvironmentPath?: (resource?: vscode.Uri) => { - path?: string; - }; - updateActiveEnvironmentPath?: ( - environment: string, - resource?: vscode.Uri, - ) => Thenable; - }; - }; - return api.environments; + this._pythonEnvApi = await PythonEnvironments.api(); } catch (e) { - log.warn(`Could not query the Python extension: ${String(e)}`); - return undefined; + log.warn(`Python Environments extension is not available: ${String(e)}`); } + return this._pythonEnvApi; } /** The Python extension's active interpreter path, if available. */ private async activeInterpreterPath(): Promise { - const environments = await this.pythonEnvironmentsApi(); - return environments?.getActiveEnvironmentPath?.()?.path; + const api = await this.pythonEnvironmentsApi(); + if (!api) return undefined; + const env = await api.getEnvironment(undefined); + return env?.execInfo.run.executable; // TODO (acasey): consume args? } /** - * Set the active interpreter for a resource via the stable Python + * Set the active interpreter for a resource via the Python Environments * extension API. The Jupyter extension uses this association to pick the * kernel for the notebook. */ @@ -446,12 +430,13 @@ export class EnvironmentManager { resource: vscode.Uri, pythonPath: string, ): Promise { - const environments = await this.pythonEnvironmentsApi(); - if (!environments?.updateActiveEnvironmentPath) { - return; - } + const api = await this.pythonEnvironmentsApi(); + if (!api) return; try { - await environments.updateActiveEnvironmentPath(pythonPath, resource); + const env = await api.resolveEnvironment(vscode.Uri.file(pythonPath)); + if (env) { + await api.setEnvironment(resource, env); + } } catch (e) { log.warn(`Could not set the active interpreter: ${String(e)}`); } From 8740f06c61e545612c8378e26174dd7185bd70a7 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 23 Jul 2026 11:14:34 -0700 Subject: [PATCH 019/101] DO NOT MERGE local launch.json --- .vscode/launch.shared.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.shared.json b/.vscode/launch.shared.json index 44ad53d3e11..ef37f664b77 100644 --- a/.vscode/launch.shared.json +++ b/.vscode/launch.shared.json @@ -10,7 +10,10 @@ "args": [ "--profile=dev", "--extensionDevelopmentPath=${workspaceFolder}/source/vscode", - "${workspaceFolder}/samples/" + "${workspaceFolder}/source/vscode/test/suites/learning/test-workspace" + ], + "outFiles": [ + "${workspaceFolder}/source/vscode/out/**/*.js" ] }, { From e4cdd6adb352ae1e92ab439e36172bd8d815f8cc Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 08:49:04 -0700 Subject: [PATCH 020/101] Replace all direct python access with env API calls --- source/vscode/src/learning/constants.ts | 4 - .../vscode/src/learning/python/environment.ts | 543 +++++------------- source/vscode/src/learning/service.ts | 184 +++--- 3 files changed, 224 insertions(+), 507 deletions(-) diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts index eb458a27bb2..126d5823f5c 100644 --- a/source/vscode/src/learning/constants.ts +++ b/source/vscode/src/learning/constants.ts @@ -23,9 +23,5 @@ export const LEARNING_WORKSPACE_DETECTED_CONTEXT = /** Course ID for the built-in Quantum Katas. */ export const KATAS_COURSE_ID = "katas"; -/** Per-course virtual environment folder (under the course working copy). */ -// TODO (acasey): is there a way we can make it search recursively during discovery? Sounds like there might be a workspace setting -export const LEARNING_VENV_DIR = ".venv"; - /** Tree view ID for the learning progress panel. */ export const LEARNING_TREE_VIEW_ID = "qsharp-vscode.learningTree"; diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 5ef392cc64f..a4b4f1793b6 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -2,43 +2,32 @@ // Licensed under the MIT License. import { log } from "qsharp-lang"; -import type { PythonEnvironmentApi } from "@vscode/python-environments"; +import type { + PythonEnvironment, + PythonEnvironmentApi, + PythonProcess, +} from "@vscode/python-environments"; import { PythonEnvironments } from "@vscode/python-environments"; import * as vscode from "vscode"; -import { LEARNING_VENV_DIR } from "../constants.js"; - -// TODO (acasey): rewrite in terms of VS Code API /** * Manages per-course Python environments for `python-notebook` courses. * - * Every operating-system interaction (creating a venv, installing - * packages, registering a Jupyter kernel) is encapsulated behind a method - * here and routed through {@link runShell} so the underlying mechanism can - * later be swapped for the Python extension's API without touching callers. - * - * All file access uses `vscode.workspace.fs` and shell work uses the - * `vscode.tasks` API, keeping this module free of Node built-ins so the - * extension still bundles for VS Code for the Web (where these desktop-only - * operations are short-circuited). + * All environment lifecycle operations (creation, package installation, + * import verification) are routed through the `@vscode/python-environments` + * API. This module is free of Node built-ins so the extension still bundles + * for VS Code for the Web (where these desktop-only operations are + * short-circuited). */ export class EnvironmentManager { - private readonly controllers = new Map(); - /** Cached result of probing for `uv` on the PATH. */ - private _uvAvailable: boolean | undefined; /** Cached Python Environments extension API (only set on success). */ private _pythonEnvApi: PythonEnvironmentApi | undefined; + /** Cached environments keyed by courseRoot.toString(). */ + // TODO (acasey): do we need a cache? + private readonly _envCache = new Map(); dispose(): void { - for (const controller of this.controllers.values()) { - controller.dispose(); - } - this.controllers.clear(); - } - - /** The course's virtual environment folder. */ - venvUri(courseRoot: vscode.Uri): vscode.Uri { - return vscode.Uri.joinPath(courseRoot, LEARNING_VENV_DIR); + this._envCache.clear(); } /** True on a host where environment management can run (desktop only). */ @@ -47,301 +36,107 @@ export class EnvironmentManager { } /** - * Locate a Python interpreter to bootstrap a venv. Prefers the Python - * extension's active interpreter, falling back to `python3`/`python`. - * Returns `undefined` when none can be determined. - */ - async ensureInterpreter(): Promise { - if (!this.supported) { - return undefined; - } - const fromExtension = await this.activeInterpreterPath(); - // TODO (acasey): confirm availability of python3? - return fromExtension ?? "python3"; - } - - /** Whether the venv already exists on disk. */ - async venvExists(courseRoot: vscode.Uri): Promise { - return uriExists(this.venvUri(courseRoot)); - } - - /** - * Create the course venv if it does not yet exist. - * - * @param pythonSpec Optional Python version specifier from `course.json` - * (e.g. `">=3.11"`, `"3.12"`). When `uv` is available this is passed - * directly to `uv venv --python ` which lets `uv` discover or - * download a matching interpreter. When `uv` is unavailable, the spec - * is ignored and the system interpreter is used. - */ - async createVenv(courseRoot: vscode.Uri, pythonSpec?: string): Promise { - if (!this.supported || (await this.venvExists(courseRoot))) { - return; - } - const cwd = courseRoot; - const venvPath = this.venvUri(courseRoot).fsPath; - - // Prefer `uv` when it's available — it's faster and is the modern - // default tooling. Fall back to the standard library `venv` module. - if (await this.uvAvailable()) { - // With `uv`, pass the version spec (e.g. ">=3.11") or fall back to - // the system default. `uv` will discover or download a matching - // interpreter automatically. - const args = ["venv"]; - if (pythonSpec) { - args.push("--python", pythonSpec); - } - args.push(venvPath); - - const code = await this.runShell( - "Create course environment", - "uv", - args, - cwd, - ); - if (code === 0) { - return; - } - log.warn(`\`uv venv\` failed (exit ${code}); falling back to venv.`); - } - - // For the stdlib fallback we need an actual interpreter path. - const python = await this.ensureInterpreter(); - if (!python) { - throw new Error("No Python interpreter was found."); - } - - // Preflight: on some distros the `venv`/`ensurepip` modules are a - // separate OS package (e.g. Debian's `python3-venv`). Detect that here - // so we can surface an actionable message instead of an opaque failure. - const preflight = await this.runShell( - "Check Python venv support", - python, - ["-c", "import venv, ensurepip"], - cwd, - ); - if (preflight !== 0) { - // TODO (acasey): use their python version number - throw new Error( - "This Python installation can't create virtual environments " + - "(the `venv`/`ensurepip` modules are missing). On Debian/Ubuntu " + - "install them with `sudo apt install python3-venv` (matching your " + - "Python version, e.g. `python3.12-venv`), then try again.", - ); - } - - const code = await this.runShell( - "Create course environment", - python, - ["-m", "venv", venvPath], - cwd, - ); - if (code !== 0) { - throw new Error( - `Creating the virtual environment failed (exit ${code}).`, - ); - } - } - - /** - * Sync the course environment from its `pyproject.toml`. Prefers `uv sync` - * when available; falls back to creating a venv with the stdlib `venv` - * module and installing with `pip install .`. + * Ensure a Python environment exists for the given course and install the + * specified packages. If an environment already exists in the target + * directory it is reused and packages are installed into it; otherwise a + * new environment is created. * * @param courseRoot The course's source folder (where `pyproject.toml` - * lives and where the `.venv` is created). - * @param pythonSpec Optional Python version specifier from course metadata - * (e.g. `">=3.11"`). Passed to {@link createVenv} in the fallback path. + * may live and where the environment is created). + * @param packages Packages to install (e.g. `["ipykernel", "qdk"]`). + * @param minPython Optional minimum Python version (e.g. `"3.11"`). If + * the created/resolved environment's Python version is below this, an + * error is thrown. */ - async syncEnvironment( + async ensureEnvironment( courseRoot: vscode.Uri, - pythonSpec?: string, + packages: string[], + minPython?: string, ): Promise { if (!this.supported) { return; } - - if (await this.uvAvailable()) { - const code = await this.runShell( - "Sync course environment", - "uv", - ["sync", "--project", courseRoot.fsPath], - courseRoot, - ); - if (code === 0) { - return; - } - log.warn( - `\`uv sync\` failed (exit ${code}); falling back to venv + pip.`, + const api = await this.pythonEnvironmentsApi(); + if (!api) { + // TODO (acasey): where does this go? + throw new Error( + "The Python Environments extension is required for Python courses. " + + "Install it from the VS Code Marketplace.", ); } - // Fallback: create a venv and install from pyproject.toml using pip. - await this.createVenv(courseRoot, pythonSpec); - const python = await this.venvPython(courseRoot); - if (!python) { - throw new Error("Failed to create the virtual environment."); - } + // Check for an existing environment in this directory. + let env = await this.findEnvironment(api, courseRoot); - const code = await this.runShell( - "Install from pyproject.toml", - python, - ["-m", "pip", "install", "--disable-pip-version-check", "."], - courseRoot, - ); - if (code !== 0) { - throw new Error( - `\`pip install .\` failed (exit ${code}). Check the terminal output for details.`, + if (env) { + log.info( + `Updating existing environment for ${courseRoot.fsPath}: ${env.name}`, ); - } - } + } else { + // Create a new environment. The API picks up pyproject.toml if present. + log.info(`Creating new environment for ${courseRoot.fsPath}`); + env = await api.createEnvironment(courseRoot, { quickCreate: true }); + if (!env) { + // TODO (acasey): where does this go? + throw new Error( + `Failed to create a Python environment in ${courseRoot.fsPath}. ` + + `Ensure the Python Environments extension has a registered environment manager.`, + ); + } - /** - * Install the course's pinned requirements into its venv. Always installs - * `ipykernel` as well so the Jupyter extension can discover and run the - * venv as a notebook kernel without a globally-registered kernelspec. - */ - async installRequirements( - courseRoot: vscode.Uri, - requirements: string[], - ): Promise { - if (!this.supported) { - return; - } - const python = await this.venvPython(courseRoot); - if (!python) { - throw new Error("The course environment is missing its interpreter."); + // Cache the resolved environment. + this._envCache.set(courseRoot.toString(), env); } - // De-duplicate while preserving order; ipykernel is required for the - // venv to act as a Jupyter kernel. - const packages = [...new Set(["ipykernel", ...requirements])]; - const cwd = courseRoot; - if (await this.uvAvailable()) { - const code = await this.runShell( - "Install course requirements", - "uv", - ["pip", "install", "--python", python, ...packages], - cwd, - ); - if (code === 0) { - return; - } - log.warn( - `\`uv pip install\` failed (exit ${code}); falling back to pip.`, + // Version check. + if (minPython && !versionSatisfies(env.version, minPython)) { + throw new Error( + `The course requires Python ${minPython} but the environment ` + + `has Python ${env.version}. Select or install a newer Python interpreter.`, ); } - const code = await this.runShell( - "Install course requirements", - python, - ["-m", "pip", "install", "--disable-pip-version-check", ...packages], - cwd, - ); - if (code !== 0) { - throw new Error(`Installing requirements failed (exit ${code}).`); + // Install packages. + if (packages.length > 0) { + log.info(`Installing packages: ${packages.join(", ")}`); + await api.managePackages(env, { install: packages }); } } /** - * Select this course's venv as the kernel/interpreter for a notebook. - * - * Uses the **stable** `ms-python.python` `environments` API - * (`updateActiveEnvironmentPath`) to set the interpreter for the notebook - * resource — the mechanism the Jupyter extension honors when picking a - * kernel — and additionally nudges the picker with a core - * {@link vscode.NotebookController} affinity hint. - * - * We deliberately do NOT register a global kernelspec - * (`ipykernel install --user`): that pollutes the user's kernel list and - * competes with the Jupyter extension's own environment discovery. - * Instead {@link installRequirements} puts `ipykernel` in the venv so - * Jupyter can discover and run it directly. + * Whether an environment exists for the given course root. */ - async selectKernelForNotebook( - notebook: vscode.NotebookDocument, - courseRoot: vscode.Uri, - courseId: string, - displayName: string, - ): Promise { + async environmentExists(courseRoot: vscode.Uri): Promise { if (!this.supported) { - return; - } - - // TODO (acasey): confirm this is working (or could work) - - // Primary, stable path: point the Python extension at the venv - // interpreter for this notebook resource. - const python = await this.venvPython(courseRoot); - if (python) { - await this.setActiveInterpreter(notebook.uri, python); - } - - // Secondary nudge: a notebook controller affinity hint. This is a core - // VS Code API (not Python-specific) and is safe to keep as a fallback. - let controller = this.controllers.get(courseId); - if (!controller) { - controller = vscode.notebooks.createNotebookController( - `qdk-learning-${courseId}`, - "jupyter-notebook", - `QDK: ${displayName}`, - ); - controller.supportedLanguages = ["python"]; - controller.description = "QDK course environment"; - this.controllers.set(courseId, controller); + return false; } - controller.updateNotebookAffinity( - notebook, - vscode.NotebookControllerAffinity.Preferred, - ); - } - - /** Path to the venv's Python interpreter, or `undefined` if not present. */ - async venvPython(courseRoot: vscode.Uri): Promise { - const venv = this.venvUri(courseRoot); - const candidates = [ - vscode.Uri.joinPath(venv, "bin", "python"), - vscode.Uri.joinPath(venv, "bin", "python3"), - vscode.Uri.joinPath(venv, "Scripts", "python.exe"), - ]; - for (const candidate of candidates) { - if (await uriExists(candidate)) { - return candidate.fsPath; - } + const api = await this.pythonEnvironmentsApi(); + if (!api) { + return false; } - return undefined; + const env = await this.findEnvironment(api, courseRoot); + return env !== undefined; } /** - * Verify the given modules import in the course venv (e.g. `qdk`, - * `qsharp_widgets`). Returns `false` if the venv or interpreter is - * missing or the import fails. + * The Python version string for the course's environment, or `undefined` + * if no environment is known. */ - async checkImports( + async environmentVersion( courseRoot: vscode.Uri, - modules: string[], - ): Promise { - if (!this.supported || modules.length === 0) { - return false; - } - const python = await this.venvPython(courseRoot); - if (!python) { - return false; + ): Promise { + const api = await this.pythonEnvironmentsApi(); + if (!api) { + return undefined; } - const code = await this.runShell( - "Verify course packages", - python, - ["-c", `import ${modules.join(", ")}`], - courseRoot, - ); - return code === 0; + const env = await this.findEnvironment(api, courseRoot); + return env?.version; } /** - * Per-module import report for the course venv. Each entry is `true` when - * that module imports successfully. Missing venv/interpreter yields all - * `false`. Used by the environment check to pinpoint which package is - * missing. + * Per-module import report for the course environment. Each entry is + * `true` when that module imports successfully. Missing environment yields + * all `false`. */ async importsReport( courseRoot: vscode.Uri, @@ -350,49 +145,24 @@ export class EnvironmentManager { if (!this.supported || modules.length === 0) { return modules.map((module) => ({ module, ok: false })); } - const python = await this.venvPython(courseRoot); - if (!python) { + const api = await this.pythonEnvironmentsApi(); + if (!api) { + return modules.map((module) => ({ module, ok: false })); + } + const env = await this.findEnvironment(api, courseRoot); + if (!env) { return modules.map((module) => ({ module, ok: false })); } + const results: { module: string; ok: boolean }[] = []; for (const module of modules) { - const code = await this.runShell( - `Check import: ${module}`, - python, - ["-c", `import ${module}`], - courseRoot, - ); + const code = await runPython(api, env, ["-c", `import ${module}`]); results.push({ module, ok: code === 0 }); } return results; } - /** Whether `uv` is available on the PATH (public diagnostics accessor). */ - async hasUv(): Promise { - return this.uvAvailable(); - } - - /** - * Whether the given interpreter can create virtual environments (the - * `venv` and `ensurepip` modules are importable). On some Linux distros - * these are a separate OS package. Defaults to the bootstrap interpreter. - */ - async venvModuleSupported(python?: string): Promise { - if (!this.supported) { - return false; - } - const interpreter = python ?? (await this.ensureInterpreter()); - if (!interpreter) { - return false; - } - const code = await this.runShell("Check Python venv support", interpreter, [ - "-c", - "import venv, ensurepip", - ]); - return code === 0; - } - - // ─── Private: swappable OS interaction ─── + // ─── Private helpers ─── /** * The Python Environments extension API, or `undefined` when the @@ -413,90 +183,77 @@ export class EnvironmentManager { return this._pythonEnvApi; } - /** The Python extension's active interpreter path, if available. */ - private async activeInterpreterPath(): Promise { - const api = await this.pythonEnvironmentsApi(); - if (!api) return undefined; - const env = await api.getEnvironment(undefined); - return env?.execInfo.run.executable; // TODO (acasey): consume args? - } - /** - * Set the active interpreter for a resource via the Python Environments - * extension API. The Jupyter extension uses this association to pick the - * kernel for the notebook. + * Find an existing environment in the given directory. */ - private async setActiveInterpreter( - resource: vscode.Uri, - pythonPath: string, - ): Promise { - const api = await this.pythonEnvironmentsApi(); - if (!api) return; - try { - const env = await api.resolveEnvironment(vscode.Uri.file(pythonPath)); - if (env) { - await api.setEnvironment(resource, env); - } - } catch (e) { - log.warn(`Could not set the active interpreter: ${String(e)}`); + private async findEnvironment( + api: PythonEnvironmentApi, + courseRoot: vscode.Uri, + ): Promise { + // Check cache first. + const cached = this._envCache.get(courseRoot.toString()); + if (cached) { + return cached; } - } - /** Whether `uv` is available on the PATH. Cached after the first probe. */ - private async uvAvailable(): Promise { - if (this._uvAvailable === undefined) { - const code = await this.runShell("Check for uv", "uv", ["--version"]); - this._uvAvailable = code === 0; + // Without a refresh, getEnvironment seems to pick up the global install + await api.refreshEnvironments(courseRoot); + const env = await api.getEnvironment(courseRoot); + if (env) { + this._envCache.set(courseRoot.toString(), env); } - return this._uvAvailable; - } - - /** - * Run a shell command as a one-shot task and resolve with its exit code. - * Centralized so the execution mechanism stays swappable. - */ - private runShell( - name: string, - command: string, - args: string[], - cwd?: vscode.Uri, - ): Promise { - // Course commands pass the course root; course-independent probes - // (`uv --version`, `python -c "import venv"`) don't depend on the cwd, - // so they fall back to the workspace folder, which is guaranteed to exist. - const cwdPath = (cwd ?? vscode.workspace.workspaceFolders?.[0]?.uri) - ?.fsPath; - const task = new vscode.Task( - { type: "qdk-learning" }, - vscode.TaskScope.Workspace, - name, - "qdk-learning", - new vscode.ShellExecution(command, args, { cwd: cwdPath }), - ); - task.presentationOptions = { - reveal: vscode.TaskRevealKind.Silent, - focus: false, - clear: false, - }; - return new Promise((resolve) => { - const sub = vscode.tasks.onDidEndTaskProcess((e) => { - if (e.execution.task === task) { - sub.dispose(); - resolve(e.exitCode ?? -1); - } - }); - void vscode.tasks.executeTask(task); - }); + return env; } } // ─── Helpers ─── -async function uriExists(uri: vscode.Uri): Promise { - try { - await vscode.workspace.fs.stat(uri); - return true; - } catch { - return false; +/** + * Run Python with the given args in the background and return the exit code. + */ +function runPython( + api: PythonEnvironmentApi, + env: PythonEnvironment, + args: string[], +): Promise { + return new Promise((resolve) => { + api + .runInBackground(env, { args }) + .then((proc: PythonProcess) => { + proc.stdout.on("data", (data) => { + log.info(`python stdout: ${String(data)}`); + }); + proc.stderr.on("data", (data) => { + log.warn(`python stderr: ${String(data)}`); + }); + proc.onExit((code) => { + resolve(code ?? -1); + }); + }) + .catch((e) => { + log.warn(`Failed to run Python: ${String(e)}`); + resolve(-1); + }); + }); +} + +/** + * Check whether a version string satisfies a minimum version requirement. + * Compares major.minor only (e.g. "3.11.2" satisfies "3.11"). + */ +function versionSatisfies(version: string, minimum: string): boolean { + // TODO (acasey): share with service.ts + const parse = (v: string) => { + const parts = v.replace(/[^0-9.]/g, "").split("."); + return { + major: parseInt(parts[0] ?? "0", 10), + minor: parseInt(parts[1] ?? "0", 10), + }; + }; + const actual = parse(version); + const required = parse(minimum); + if (actual.major !== required.major) { + return actual.major > required.major; } + return actual.minor >= required.minor; } diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index c96dfceff48..695702b4afe 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -562,13 +562,9 @@ export class LearningService { /** * Ensure a python-notebook course's per-course environment exists: - * create the venv and install pinned requirements. No-ops for Q# courses, - * on the Web, or when the venv already exists (unless `force` is set). - * - * When the course ships a `pyproject.toml`, the preferred path is - * `uv sync` which handles venv creation, Python version selection, and - * dependency installation in one shot. Courses without `pyproject.toml` - * fall back to the manual `createVenv` + `installRequirements` flow. + * create or update the environment and install required packages. No-ops + * for Q# courses, on the Web, or when the environment already exists + * (unless `force` is set). */ async ensureEnvironment( course: CatalogCourse, @@ -585,30 +581,23 @@ export class LearningService { return; } const courseRoot = vscode.Uri.parse(course.sourceDir); - if (!options?.force && (await env.venvExists(courseRoot))) { + if (!options?.force && (await env.environmentExists(courseRoot))) { return; } + const packages = [ + ...new Set(["ipykernel", ...(course.environment?.requirements ?? [])]), + ]; await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Setting up the environment for "${course.title}"…`, }, async () => { - const hasPyproject = await this.uriExists( - vscode.Uri.joinPath(courseRoot, "pyproject.toml"), + await env.ensureEnvironment( + courseRoot, + packages, + course.environment?.python, ); - if (hasPyproject) { - // Preferred: `uv sync` resolves and installs from pyproject.toml. - // Falls back to venv + pip when uv is unavailable. - await env.syncEnvironment(courseRoot, course.environment?.python); - } else { - // Fallback: manual venv creation + pip install. - await env.createVenv(courseRoot, course.environment?.python); - await env.installRequirements( - courseRoot, - course.environment?.requirements ?? [], - ); - } }, ); } @@ -706,7 +695,6 @@ export class LearningService { checks.push( check( "extensions", - // TODO (acasey): Shouldn't need to keep these in sync with ensureExtensions "Python & Jupyter extensions", extMessage ? "fail" : "ok", { @@ -721,101 +709,57 @@ export class LearningService { ), ); - // 2. Base Python interpreter (for bootstrapping the venv). - log.info(`[env-check] Checking interpreter…`); - const interpreter = await env.ensureInterpreter(); - log.info(`[env-check] Interpreter: ${interpreter ?? "not found"}`); + // 2. The per-course environment. + log.info(`[env-check] Checking environment existence…`); + const envExists = await env.environmentExists(courseRoot); + log.info(`[env-check] Environment exists: ${envExists}`); checks.push( - check("interpreter", "Python interpreter", interpreter ? "ok" : "fail", { - detail: interpreter ?? "No interpreter found.", - hint: interpreter + check("venv", "Course environment", envExists ? "ok" : "fail", { + detail: envExists + ? "Environment found." + : "No environment found for this course.", + hint: envExists + ? undefined + : "Run environment setup to create the course environment.", + fixes: envExists ? undefined - : // TODO (acasey): how did we pick 3.9? - "Install Python (3.9+) and select an interpreter via the Python extension.", + : [{ label: "Set up environment", kind: "setup" }], }), ); - // 3. Tooling: uv (preferred) vs stdlib venv. Informational unless the - // venv is missing AND the stdlib module is unavailable. - log.info(`[env-check] Checking for uv…`); - const hasUv = await env.hasUv(); - log.info(`[env-check] uv available: ${hasUv}`); - log.info(`[env-check] Checking venv existence…`); - const venvOk = await env.venvExists(courseRoot); - log.info(`[env-check] Venv exists: ${venvOk}`); - if (hasUv) { - checks.push( - check("tooling", "Environment tooling", "ok", { - detail: "uv detected — fast environment creation.", - }), + // 3. Python version sufficiency. + if (envExists) { + const version = await env.environmentVersion(courseRoot); + const minPython = course.environment?.python; + log.info( + `[env-check] Python version: ${version ?? "unknown"}, required: ${minPython ?? "any"}`, ); - } else if (interpreter) { - // Only probe the stdlib venv module when we'd actually need it. - log.info(`[env-check] Probing stdlib venv module…`); - const venvModuleOk = venvOk - ? true - : await env.venvModuleSupported(interpreter); - log.info(`[env-check] venv module supported: ${venvModuleOk}`); - checks.push( - check( - "tooling", - "Environment tooling", - venvModuleOk ? "warn" : "fail", - { - detail: venvModuleOk - ? // TODO (acasey): do we want to recommend uv? - "Using the standard-library `venv` (install `uv` for faster setup)." - : "The `venv`/`ensurepip` modules are missing from this Python.", - hint: venvModuleOk + if (version && minPython) { + const versionOk = this.versionSatisfies(version, minPython); + checks.push( + check("python-version", "Python version", versionOk ? "ok" : "fail", { + detail: versionOk + ? `Python ${version} (meets ${minPython} requirement).` + : `Python ${version} does not meet the ${minPython} requirement.`, + hint: versionOk ? undefined - : // TODO (acasey): can we determine the actual version number? - "On Debian/Ubuntu install them with `sudo apt install python3-venv` " + - "(matching your Python version, e.g. `python3.12-venv`).", - }, - ), - ); + : "Select or install a newer Python interpreter and re-run setup.", + fixes: versionOk + ? undefined + : [{ label: "Set up environment", kind: "setup" }], + }), + ); + } else if (version) { + checks.push( + check("python-version", "Python version", "ok", { + detail: `Python ${version}.`, + }), + ); + } } - // 4. The per-course virtual environment. - checks.push( - check("venv", "Course virtual environment", venvOk ? "ok" : "fail", { - detail: env.venvUri(courseRoot).fsPath, - hint: venvOk - ? undefined - : "Run environment setup to create the course virtual environment.", - fixes: venvOk - ? undefined - : [{ label: "Set up environment", kind: "setup" }], - }), - ); - - log.info(`[env-check] Checking venv interpreter…`); - const venvPython = venvOk ? await env.venvPython(courseRoot) : undefined; - log.info(`[env-check] Venv interpreter: ${venvPython ?? "n/a"}`); - checks.push( - check( - "venv-interpreter", - "Environment interpreter", - !venvOk ? "skip" : venvPython ? "ok" : "fail", - { - detail: !venvOk - ? "No environment yet." - : (venvPython ?? "The venv exists but has no interpreter."), - hint: - venvOk && !venvPython - ? "The environment looks corrupt; re-run setup to recreate it." - : undefined, - fixes: - venvOk && !venvPython - ? [{ label: "Set up environment", kind: "setup" }] - : undefined, - }, - ), - ); - - // 5. Required packages import in the venv. - if (venvPython) { - // TODO (acasey): are these supposed to come from the course metadata or are these just a baseline for all courses? + // 4. Required packages import in the environment. + if (envExists) { log.info(`[env-check] Checking package imports…`); const report = await env.importsReport(courseRoot, [ "qdk", @@ -847,7 +791,7 @@ export class LearningService { ), ); } else { - log.info(`[env-check] Skipping package imports — no venv interpreter.`); + log.info(`[env-check] Skipping package imports — no environment.`); checks.push( check("packages", "Required packages", "skip", { detail: "No environment yet.", @@ -859,6 +803,26 @@ export class LearningService { return this.assembleReport(course, checks); } + /** + * Check whether a version string satisfies a minimum version requirement. + * Compares major.minor only (e.g. "3.11.2" satisfies "3.11"). + */ + private versionSatisfies(version: string, minimum: string): boolean { + const parse = (v: string) => { + const parts = v.replace(/[^0-9.]/g, "").split("."); + return { + major: parseInt(parts[0] ?? "0", 10), + minor: parseInt(parts[1] ?? "0", 10), + }; + }; + const actual = parse(version); + const required = parse(minimum); + if (actual.major !== required.major) { + return actual.major > required.major; + } + return actual.minor >= required.minor; + } + /** * Fold a list of diagnostic checks into an {@link EnvironmentCheckReport}: * compute the overall status, a human summary, and the de-duplicated fix From f3982ef7aee14ebe405bd7f7b19a823600f75c06 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 09:39:16 -0700 Subject: [PATCH 021/101] Detect the absence of a venv --- source/vscode/src/learning/python/environment.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index a4b4f1793b6..ab48aa213db 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -58,7 +58,7 @@ export class EnvironmentManager { } const api = await this.pythonEnvironmentsApi(); if (!api) { - // TODO (acasey): where does this go? + // TODO (acasey): this goes to the extension host output window, which isn't useful throw new Error( "The Python Environments extension is required for Python courses. " + "Install it from the VS Code Marketplace.", @@ -200,6 +200,16 @@ export class EnvironmentManager { await api.refreshEnvironments(courseRoot); const env = await api.getEnvironment(courseRoot); if (env) { + // If there's no local venv, getEnvironment will return the global install + const envPath = env.environmentPath.toString(); + const rootPath = courseRoot.toString().replace(/\/?$/, "/"); + if (!envPath.startsWith(rootPath)) { + log.debug( + `Ignoring environment "${env.name}" at ${envPath} ` + + `because it is not under ${rootPath}`, + ); + return undefined; + } this._envCache.set(courseRoot.toString(), env); } return env; From 824913cb2f13d0156e2697bfe297359ca08cebc2 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 09:51:51 -0700 Subject: [PATCH 022/101] Drop most of CourseEnvironment --- .../src/learning/dropInCourseProvider.ts | 13 ---- .../vscode/src/learning/python/environment.ts | 48 -------------- source/vscode/src/learning/service.ts | 62 +------------------ source/vscode/src/learning/types.d.ts | 17 +---- 4 files changed, 3 insertions(+), 137 deletions(-) diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 9248f95a8e6..214d3edb0d1 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -344,23 +344,10 @@ function manifestEnvironment(value: unknown): CourseEnvironment | undefined { return undefined; } const obj = value as { - requirements?: unknown; - python?: unknown; importChecks?: unknown; }; const env: CourseEnvironment = {}; - if ( - Array.isArray(obj.requirements) && - obj.requirements.every((r) => typeof r === "string") - ) { - env.requirements = obj.requirements as string[]; - } - - if (typeof obj.python === "string" && obj.python.length > 0) { - env.python = obj.python; - } - if ( Array.isArray(obj.importChecks) && obj.importChecks.every((r) => typeof r === "string") diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index ab48aa213db..68ecbe12500 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -44,14 +44,10 @@ export class EnvironmentManager { * @param courseRoot The course's source folder (where `pyproject.toml` * may live and where the environment is created). * @param packages Packages to install (e.g. `["ipykernel", "qdk"]`). - * @param minPython Optional minimum Python version (e.g. `"3.11"`). If - * the created/resolved environment's Python version is below this, an - * error is thrown. */ async ensureEnvironment( courseRoot: vscode.Uri, packages: string[], - minPython?: string, ): Promise { if (!this.supported) { return; @@ -88,14 +84,6 @@ export class EnvironmentManager { this._envCache.set(courseRoot.toString(), env); } - // Version check. - if (minPython && !versionSatisfies(env.version, minPython)) { - throw new Error( - `The course requires Python ${minPython} but the environment ` + - `has Python ${env.version}. Select or install a newer Python interpreter.`, - ); - } - // Install packages. if (packages.length > 0) { log.info(`Installing packages: ${packages.join(", ")}`); @@ -118,21 +106,6 @@ export class EnvironmentManager { return env !== undefined; } - /** - * The Python version string for the course's environment, or `undefined` - * if no environment is known. - */ - async environmentVersion( - courseRoot: vscode.Uri, - ): Promise { - const api = await this.pythonEnvironmentsApi(); - if (!api) { - return undefined; - } - const env = await this.findEnvironment(api, courseRoot); - return env?.version; - } - /** * Per-module import report for the course environment. Each entry is * `true` when that module imports successfully. Missing environment yields @@ -246,24 +219,3 @@ function runPython( }); }); } - -/** - * Check whether a version string satisfies a minimum version requirement. - * Compares major.minor only (e.g. "3.11.2" satisfies "3.11"). - */ -function versionSatisfies(version: string, minimum: string): boolean { - // TODO (acasey): share with service.ts - const parse = (v: string) => { - const parts = v.replace(/[^0-9.]/g, "").split("."); - return { - major: parseInt(parts[0] ?? "0", 10), - minor: parseInt(parts[1] ?? "0", 10), - }; - }; - const actual = parse(version); - const required = parse(minimum); - if (actual.major !== required.major) { - return actual.major > required.major; - } - return actual.minor >= required.minor; -} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 695702b4afe..ab9a14a9935 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -584,20 +584,13 @@ export class LearningService { if (!options?.force && (await env.environmentExists(courseRoot))) { return; } - const packages = [ - ...new Set(["ipykernel", ...(course.environment?.requirements ?? [])]), - ]; await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Setting up the environment for "${course.title}"…`, }, async () => { - await env.ensureEnvironment( - courseRoot, - packages, - course.environment?.python, - ); + await env.ensureEnvironment(courseRoot, ["ipykernel"]); }, ); } @@ -727,38 +720,7 @@ export class LearningService { }), ); - // 3. Python version sufficiency. - if (envExists) { - const version = await env.environmentVersion(courseRoot); - const minPython = course.environment?.python; - log.info( - `[env-check] Python version: ${version ?? "unknown"}, required: ${minPython ?? "any"}`, - ); - if (version && minPython) { - const versionOk = this.versionSatisfies(version, minPython); - checks.push( - check("python-version", "Python version", versionOk ? "ok" : "fail", { - detail: versionOk - ? `Python ${version} (meets ${minPython} requirement).` - : `Python ${version} does not meet the ${minPython} requirement.`, - hint: versionOk - ? undefined - : "Select or install a newer Python interpreter and re-run setup.", - fixes: versionOk - ? undefined - : [{ label: "Set up environment", kind: "setup" }], - }), - ); - } else if (version) { - checks.push( - check("python-version", "Python version", "ok", { - detail: `Python ${version}.`, - }), - ); - } - } - - // 4. Required packages import in the environment. + // 3. Required packages import in the environment. if (envExists) { log.info(`[env-check] Checking package imports…`); const report = await env.importsReport(courseRoot, [ @@ -803,26 +765,6 @@ export class LearningService { return this.assembleReport(course, checks); } - /** - * Check whether a version string satisfies a minimum version requirement. - * Compares major.minor only (e.g. "3.11.2" satisfies "3.11"). - */ - private versionSatisfies(version: string, minimum: string): boolean { - const parse = (v: string) => { - const parts = v.replace(/[^0-9.]/g, "").split("."); - return { - major: parseInt(parts[0] ?? "0", 10), - minor: parseInt(parts[1] ?? "0", 10), - }; - }; - const actual = parse(version); - const required = parse(minimum); - if (actual.major !== required.major) { - return actual.major > required.major; - } - return actual.minor >= required.minor; - } - /** * Fold a list of diagnostic checks into an {@link EnvironmentCheckReport}: * compute the overall status, a human summary, and the de-duplicated fix diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index 08713d0b925..f42cdd8032d 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -333,24 +333,9 @@ export interface CourseDescriptor { /** * Environment requirements for a course (python-notebook courses). - * - * Courses that ship a `pyproject.toml` use `uv sync` for environment setup; - * the `python` and `requirements` fields are only used as a legacy fallback - * when no `pyproject.toml` is present. + * Used for things that can't be specified in `pyproject.toml`. */ export interface CourseEnvironment { - /** - * Python version specifier for the course venv (e.g. `">=3.11"`, `"3.12"`). - * Legacy: used only when no `pyproject.toml` is present. Prefer declaring - * `requires-python` in `pyproject.toml` instead. - */ - python?: string; - /** - * Python package requirements (e.g. `["qdk[jupyter]>=1.0", "ipympl"]`). - * Legacy: used only when no `pyproject.toml` is present. Prefer declaring - * `dependencies` in `pyproject.toml` instead. - */ - requirements?: string[]; /** * Module names to probe with `importlib.util.find_spec` in the notebook's * environment check cell (e.g. `["qdk", "qdk.widgets"]`). These are From 57f58741992f07e37eef5a6632dae181420d34a8 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 09:58:55 -0700 Subject: [PATCH 023/101] Wire up course.json import checks --- source/vscode/src/learning/service.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index ab9a14a9935..4143aa1b0c6 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -721,12 +721,10 @@ export class LearningService { ); // 3. Required packages import in the environment. - if (envExists) { + const importChecks = course.environment?.importChecks ?? []; + if (envExists && importChecks.length > 0) { log.info(`[env-check] Checking package imports…`); - const report = await env.importsReport(courseRoot, [ - "qdk", - "qsharp_widgets", - ]); + const report = await env.importsReport(courseRoot, importChecks); const missing = report.filter((r) => !r.ok).map((r) => r.module); log.info( `[env-check] Import results: ${report.map((r) => `${r.module}=${r.ok ? "ok" : "fail"}`).join(", ")}`, @@ -752,7 +750,7 @@ export class LearningService { }, ), ); - } else { + } else if (importChecks.length > 0) { log.info(`[env-check] Skipping package imports — no environment.`); checks.push( check("packages", "Required packages", "skip", { From 9ed8a0819e0e53b70471c3e5c96d64689f6976b0 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 11:18:42 -0700 Subject: [PATCH 024/101] Use requirements.txt instead of pyproject.toml for easier parsing --- .../courses/circuit-diagrams-new/pyproject.toml | 10 ---------- .../courses/circuit-diagrams-new/requirements.txt | 3 +++ 2 files changed, 3 insertions(+), 10 deletions(-) delete mode 100644 source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/pyproject.toml create mode 100644 source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/requirements.txt diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/pyproject.toml b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/pyproject.toml deleted file mode 100644 index fe6d380b5a5..00000000000 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/pyproject.toml +++ /dev/null @@ -1,10 +0,0 @@ -[project] -name = "circuit-diagrams" -version = "0.1.0" -description = "QDK Course: Generating Circuit Diagrams" -requires-python = ">=3.11" -dependencies = [ - "qdk[jupyter]>=1.29", - "ipympl>=0.10", - "ipykernel>=7.3", -] diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/requirements.txt b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/requirements.txt new file mode 100644 index 00000000000..fcbae74b5d9 --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/requirements.txt @@ -0,0 +1,3 @@ +qdk[jupyter]>=1.29 +ipympl>=0.10 +ipykernel>=7.3 From ce9797ea195054e3af729e2068451882b7d83300 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 11:18:42 -0700 Subject: [PATCH 025/101] Handle requirements.txt ourselves --- .../vscode/src/learning/python/environment.ts | 34 +++++++++++++++---- source/vscode/src/learning/service.ts | 2 +- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 68ecbe12500..8a265e27db7 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -37,18 +37,14 @@ export class EnvironmentManager { /** * Ensure a Python environment exists for the given course and install the - * specified packages. If an environment already exists in the target + * packages listed in requirements.txt. If an environment already exists in the target * directory it is reused and packages are installed into it; otherwise a * new environment is created. * * @param courseRoot The course's source folder (where `pyproject.toml` * may live and where the environment is created). - * @param packages Packages to install (e.g. `["ipykernel", "qdk"]`). */ - async ensureEnvironment( - courseRoot: vscode.Uri, - packages: string[], - ): Promise { + async ensureEnvironment(courseRoot: vscode.Uri): Promise { if (!this.supported) { return; } @@ -84,6 +80,10 @@ export class EnvironmentManager { this._envCache.set(courseRoot.toString(), env); } + // The environments API doesn't presently support parsing requirements.txt or pyproject.toml, + // so we have to do it ourselves. Hopefully, this will be folded into createEnvironment at some point. + const packages = await this.readRequirements(courseRoot); + // Install packages. if (packages.length > 0) { log.info(`Installing packages: ${packages.join(", ")}`); @@ -137,6 +137,28 @@ export class EnvironmentManager { // ─── Private helpers ─── + private async readRequirements(courseRoot: vscode.Uri): Promise { + const requirementsUri = vscode.Uri.joinPath(courseRoot, "requirements.txt"); + try { + const contents = new TextDecoder().decode( + await vscode.workspace.fs.readFile(requirementsUri), + ); + return contents + .split(/\r?\n/) + .map((requirement) => requirement.trim()) + .filter( + (requirement) => + requirement.length > 0 && !requirement.startsWith("#"), + ); + } catch (e) { + if (e instanceof vscode.FileSystemError && e.code === "FileNotFound") { + log.warn(`No requirements.txt found under ${courseRoot.fsPath}`); + return []; + } + throw e; + } + } + /** * The Python Environments extension API, or `undefined` when the * extension is unavailable. A successful lookup is cached; failures diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 4143aa1b0c6..ef1d9d489dc 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -590,7 +590,7 @@ export class LearningService { title: `Setting up the environment for "${course.title}"…`, }, async () => { - await env.ensureEnvironment(courseRoot, ["ipykernel"]); + await env.ensureEnvironment(courseRoot); }, ); } From 47077368201c13527b54b10d58bf1f698656fffb Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 11:35:40 -0700 Subject: [PATCH 026/101] Remove requirements.txt parsing --- .../vscode/src/learning/python/environment.ts | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 8a265e27db7..052f9b9915b 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -65,7 +65,8 @@ export class EnvironmentManager { `Updating existing environment for ${courseRoot.fsPath}: ${env.name}`, ); } else { - // Create a new environment. The API picks up pyproject.toml if present. + // Create a new environment. The API picks up requirements.txt, if present. + // As of July 2026, it will not parse pyproject.toml. log.info(`Creating new environment for ${courseRoot.fsPath}`); env = await api.createEnvironment(courseRoot, { quickCreate: true }); if (!env) { @@ -79,16 +80,6 @@ export class EnvironmentManager { // Cache the resolved environment. this._envCache.set(courseRoot.toString(), env); } - - // The environments API doesn't presently support parsing requirements.txt or pyproject.toml, - // so we have to do it ourselves. Hopefully, this will be folded into createEnvironment at some point. - const packages = await this.readRequirements(courseRoot); - - // Install packages. - if (packages.length > 0) { - log.info(`Installing packages: ${packages.join(", ")}`); - await api.managePackages(env, { install: packages }); - } } /** @@ -137,28 +128,6 @@ export class EnvironmentManager { // ─── Private helpers ─── - private async readRequirements(courseRoot: vscode.Uri): Promise { - const requirementsUri = vscode.Uri.joinPath(courseRoot, "requirements.txt"); - try { - const contents = new TextDecoder().decode( - await vscode.workspace.fs.readFile(requirementsUri), - ); - return contents - .split(/\r?\n/) - .map((requirement) => requirement.trim()) - .filter( - (requirement) => - requirement.length > 0 && !requirement.startsWith("#"), - ); - } catch (e) { - if (e instanceof vscode.FileSystemError && e.code === "FileNotFound") { - log.warn(`No requirements.txt found under ${courseRoot.fsPath}`); - return []; - } - throw e; - } - } - /** * The Python Environments extension API, or `undefined` when the * extension is unavailable. A successful lookup is cached; failures From 40bbd28d54ad81c00330fd3ed1abea6d56e5f799 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 11:37:47 -0700 Subject: [PATCH 027/101] Don't throw from ensureEnvironment --- source/vscode/src/learning/python/environment.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 052f9b9915b..371e329a7ee 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -50,11 +50,11 @@ export class EnvironmentManager { } const api = await this.pythonEnvironmentsApi(); if (!api) { - // TODO (acasey): this goes to the extension host output window, which isn't useful - throw new Error( + log.warn( "The Python Environments extension is required for Python courses. " + "Install it from the VS Code Marketplace.", ); + return; } // Check for an existing environment in this directory. @@ -70,11 +70,11 @@ export class EnvironmentManager { log.info(`Creating new environment for ${courseRoot.fsPath}`); env = await api.createEnvironment(courseRoot, { quickCreate: true }); if (!env) { - // TODO (acasey): where does this go? - throw new Error( + log.warn( `Failed to create a Python environment in ${courseRoot.fsPath}. ` + `Ensure the Python Environments extension has a registered environment manager.`, ); + return; } // Cache the resolved environment. From 18a5b3c088fd02aca463b5666ecd8ff23ead9da8 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 11:39:29 -0700 Subject: [PATCH 028/101] Tidy up environments.ts --- source/vscode/src/learning/python/environment.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 371e329a7ee..401c0acaf60 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -23,11 +23,13 @@ export class EnvironmentManager { /** Cached Python Environments extension API (only set on success). */ private _pythonEnvApi: PythonEnvironmentApi | undefined; /** Cached environments keyed by courseRoot.toString(). */ - // TODO (acasey): do we need a cache? - private readonly _envCache = new Map(); + private readonly _projectEnvironmentMap = new Map< + string, + PythonEnvironment + >(); dispose(): void { - this._envCache.clear(); + this._projectEnvironmentMap.clear(); } /** True on a host where environment management can run (desktop only). */ @@ -78,7 +80,7 @@ export class EnvironmentManager { } // Cache the resolved environment. - this._envCache.set(courseRoot.toString(), env); + this._projectEnvironmentMap.set(courseRoot.toString(), env); } } @@ -155,7 +157,7 @@ export class EnvironmentManager { courseRoot: vscode.Uri, ): Promise { // Check cache first. - const cached = this._envCache.get(courseRoot.toString()); + const cached = this._projectEnvironmentMap.get(courseRoot.toString()); if (cached) { return cached; } @@ -174,7 +176,7 @@ export class EnvironmentManager { ); return undefined; } - this._envCache.set(courseRoot.toString(), env); + this._projectEnvironmentMap.set(courseRoot.toString(), env); } return env; } From f6949209d9ed6b76dfb9d2b2e81a4a511648ea6a Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 11:44:21 -0700 Subject: [PATCH 029/101] Drop readme location from course.json --- source/vscode/src/learning/constants.ts | 3 +++ source/vscode/src/learning/dropInCourseProvider.ts | 13 ++++--------- .../courses/circuit-diagrams-new/course.json | 1 - 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts index 126d5823f5c..5a66686aead 100644 --- a/source/vscode/src/learning/constants.ts +++ b/source/vscode/src/learning/constants.ts @@ -16,6 +16,9 @@ export const LEARNING_COURSES_SUBDIR = "courses"; /** Filename describing a drop-in course. */ export const COURSE_MANIFEST_FILE = "course.json"; +/** Filename containing the overview for a drop-in course. */ +export const COURSE_README_FILE = "README.md"; + /** Context key set when a learning workspace is detected. */ export const LEARNING_WORKSPACE_DETECTED_CONTEXT = "qsharp-vscode.learningWorkspaceDetected"; diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 214d3edb0d1..f5fd919ff7f 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -7,6 +7,7 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { COURSE_MANIFEST_FILE, + COURSE_README_FILE, LEARNING_COURSES_SUBDIR, LEARNING_WORKSPACE_FOLDER, } from "./constants.js"; @@ -31,7 +32,6 @@ interface CourseManifest { id?: unknown; title?: unknown; shortDescription?: unknown; - readme?: unknown; units?: unknown; environment?: unknown; } @@ -126,7 +126,6 @@ export class DropInCourseProvider implements CourseProvider { private async readManifest( dir: vscode.Uri, ): Promise { - // TODO (acasey): probably doesn't need to include readme.md - we know where that is const manifestUri = vscode.Uri.joinPath(dir, COURSE_MANIFEST_FILE); const text = await tryReadText(manifestUri); if (text === undefined) { @@ -167,13 +166,9 @@ export class DropInCourseProvider implements CourseProvider { shortDescription: manifestString(loc.manifest.shortDescription), environment: manifestEnvironment(loc.manifest.environment), }; - // TODO (acasey): well-known location (or eliminate) - const readme = manifestString(loc.manifest.readme); - if (readme) { - const readmeUri = vscode.Uri.joinPath(loc.dir, readme); - if (await uriExists(readmeUri)) { - descriptor.readmePath = readmeUri.toString(); - } + const readmeUri = vscode.Uri.joinPath(loc.dir, COURSE_README_FILE); + if (await uriExists(readmeUri)) { + descriptor.readmePath = readmeUri.toString(); } return descriptor; } diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/course.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/course.json index fae2935a230..d9ed3544866 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/course.json +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/course.json @@ -3,7 +3,6 @@ "id": "circuit-diagrams", "title": "Generating Circuit Diagrams", "shortDescription": "Build and visualize quantum circuits with the QDK in Python notebooks.", - "readme": "README.md", "units": [ { "id": "intro", From 3ad7b6cc4af3ba62381afe76e4057fb9c045a4e3 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 13:02:28 -0700 Subject: [PATCH 030/101] Drop commands and pyproject.toml from _check_env.py --- .../circuit-diagrams-new/_check_env.py | 60 +++++-------------- 1 file changed, 14 insertions(+), 46 deletions(-) diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py index 5c0561440a8..5b27d47bd4a 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py @@ -27,16 +27,15 @@ def check(notebook_dir: str | Path | None = None) -> None: nb_dir = Path(notebook_dir) if notebook_dir else Path.cwd() # --- Locate course.json --- - course_json = _find_course_json(nb_dir) - if course_json is None: + course_json_path = _find_course_json(nb_dir) + if course_json_path is None: raise FileNotFoundError( "Could not find course.json. Make sure you opened this notebook " "from the QDK course folder." ) - course = json.loads(course_json.read_text()) + course = json.loads(course_json_path.read_text()) env_cfg = course.get("environment", {}) - requirements = env_cfg.get("requirements", []) import_checks = env_cfg.get("importChecks", []) results: list[tuple[str, str, bool]] = [] # (label, detail, ok) @@ -47,27 +46,27 @@ def check(notebook_dir: str | Path | None = None) -> None: results.append(("Python version", py_version, True)) # --- Check 2: course .venv exists and has a Python interpreter --- - course_root = course_json.resolve().parent + course_root = course_json_path.resolve().parent expected_venv = (course_root / ".venv").resolve() venv_exists = expected_venv.is_dir() venv_python = _find_venv_python(expected_venv) if venv_exists else None if not venv_exists: + # TODO (acasey): update this to refer to the toolbar button when it exists results.append(("Course venv", f"{expected_venv} — not found", False)) errors.append( "The course virtual environment does not exist yet.
    " "Run QDK Learning: Doctor from the Command Palette " "(Ctrl+Shift+P / Cmd+Shift+P) " "and choose Set up environment." - + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") ) elif not venv_python: + # TODO (acasey): update this to refer to the toolbar button when it exists results.append(("Course venv", f"{expected_venv} — corrupt (no python)", False)) errors.append( "The course virtual environment exists but has no Python interpreter.
    " "Run QDK Learning: Doctor from the Command Palette " "and choose Set up environment to recreate it." - + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") ) else: results.append(("Course venv", str(expected_venv), True)) @@ -86,8 +85,8 @@ def check(notebook_dir: str | Path | None = None) -> None: if venv_exists and venv_python and not in_course_venv: results.append(("Kernel", f"Expected {expected_venv}, got {prefix}", False)) errors.append( - "This kernel is not the course environment. " - "Click Select Kernel (top-right of the notebook) " + "It is recommended, but not required, that you use the course virtual environment. " + "To do so, you can click Select Kernel (top-right of the notebook) " "and pick the course .venv, then re-run this cell." ) @@ -98,23 +97,11 @@ def check(notebook_dir: str | Path | None = None) -> None: results.append( ("Packages", ", ".join(f"{m} missing" for m in missing), False) ) - # Check if this course uses pyproject.toml (uv sync) or legacy requirements. - has_pyproject = (course_root / "pyproject.toml").exists() - if has_pyproject: - errors.append( - "Some packages are missing from the course environment.
    " - "Run QDK Learning: Doctor to re-sync, or manually run " - "uv sync in the course folder." - + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") - ) - else: - pip_cmd = f"%pip install {' '.join(requirements)}" - errors.append( - "Install missing packages by running this in a new cell, then re-run this one:" - f"
      {pip_cmd}
    " - "Or run QDK Learning: Doctor to set up the full environment." - + _command_link("qsharp-vscode.learningDoctor", "Run Doctor now") - ) + errors.append( + "Install missing packages by running this in a new cell, then re-run this one:" + f"
      %pip install -r requirements.txt
    " + "Or run QDK Learning: Doctor to set up the full environment." + ) elif import_checks and in_course_venv: results.append(("Packages", ", ".join(import_checks), True)) @@ -148,26 +135,6 @@ def _find_venv_python(venv: Path) -> Path | None: return None -# TODO (acasey): these links don't seem to work in the notebook sandbox (either in input or output cells) -# Probably want to refer people to the lesson panel and/or the command palette -# TODO (acasey): the diagnostics don't appear to check whether the venv is active in the python notebook -def _command_link(command_id: str, label: str) -> str: - """Return an HTML link that invokes a VS Code command when clicked. - - VS Code renders `vscode://` and `command:` URIs in trusted notebook - HTML output, so clicking the link runs the command directly. - """ - from urllib.parse import quote - - return ( - f'
    ' - f"{label}" - ) - - def _find_course_json(nb_dir: Path) -> Path | None: """Walk up from nb_dir looking for course.json.""" candidate = nb_dir / "course.json" @@ -184,6 +151,7 @@ def _find_course_json(nb_dir: Path) -> Path | None: return None +# TODO (acasey): handle dark mode def _render(results: list[tuple[str, str, bool]], errors: list[str]) -> None: """Display a styled HTML summary.""" rows = "" From a0e70ca46fcd1ad949c20b1219e5e8a16283b70e Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 13:18:58 -0700 Subject: [PATCH 031/101] Make report theme-safe --- .../circuit-diagrams-new/_check_env.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py index 5b27d47bd4a..2ac8f59ed1b 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py @@ -99,7 +99,7 @@ def check(notebook_dir: str | Path | None = None) -> None: ) errors.append( "Install missing packages by running this in a new cell, then re-run this one:" - f"
      %pip install -r requirements.txt
    " + f"
      %pip install -r ../requirements.txt
    " "Or run QDK Learning: Doctor to set up the full environment." ) elif import_checks and in_course_venv: @@ -151,15 +151,19 @@ def _find_course_json(nb_dir: Path) -> Path | None: return None -# TODO (acasey): handle dark mode def _render(results: list[tuple[str, str, bool]], errors: list[str]) -> None: """Display a styled HTML summary.""" rows = "" for label, detail, ok in results: icon = "✅" if ok else "❌" - color = "#2e7d32" if ok else "#c62828" + color = ( + "var(--vscode-testing-iconPassed, #00ff00)" + if ok + else "var(--vscode-testing-iconFailed, #ff00ff)" + ) rows += ( - f'' + '' f'{icon}' f'{label}' f'{detail}' @@ -167,7 +171,8 @@ def _render(results: list[tuple[str, str, bool]], errors: list[str]) -> None: ) html = ( - '
    ' + '
    ' '' f"{rows}" "
    " @@ -177,7 +182,10 @@ def _render(results: list[tuple[str, str, bool]], errors: list[str]) -> None: error_items = "".join(f"
  • {e}
  • " for e in errors) html += ( '
    ' f"Action needed:
      {error_items}
    " "
    " @@ -185,7 +193,10 @@ def _render(results: list[tuple[str, str, bool]], errors: list[str]) -> None: else: html += ( '
    ' "Environment looks good. You're ready to continue!" "
    " From 3b38421a66ea1c1a5609a1fcd3ab4293723916bf Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 13:52:38 -0700 Subject: [PATCH 032/101] First cut at opening notebook with specific kernel using unstable API --- source/vscode/src/learning/commands.ts | 3 ++- source/vscode/src/learning/panel.ts | 18 ++++++++++++- .../vscode/src/learning/python/environment.ts | 25 +++++++++++++++++++ source/vscode/src/learning/service.ts | 16 ++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 30bfdfa9fec..1133adb8aaa 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -98,7 +98,8 @@ export function registerLearningCommands( node.kind === "activity" && node.activity.type === "exercise" ) { - // TODO (acasey): is there a way to focus on a particular cell? (maybe goToExerciseByCellId?) + // TODO (acasey): is there a way to focus on a particular cell? (maybe goToExerciseByCellId?) + // TODO (acasey): reconcile with code for opening notebook in panel.ts const notebookUri = service.getCurrentCodeFileUri(); if (notebookUri) { await vscode.commands.executeCommand( diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index dcb153c6e2b..3f86a53c933 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -418,7 +418,8 @@ export class LessonPanelManager { } /** - * Open the current unit's notebook in the Jupyter editor (column 2). + * Open the current unit's notebook in the Jupyter editor (column 2), + * pre-selecting the course's Python environment as the active kernel. */ private async openCourseNotebook(): Promise { if (!this.service.initialized) { @@ -434,6 +435,21 @@ export class LessonPanelManager { orientation: 0, groups: [{ size: 0.35 }, { size: 0.65 }], }); + + // Try to open via the Jupyter extension's unstable API so the course's + // Python environment is automatically set as the active kernel. + const envPath = await this.service.getActiveCourseEnvironmentPath(); + if (envPath) { + const jupyter = + vscode.extensions.getExtension("ms-toolsai.jupyter"); + const api = await jupyter?.activate(); + if (api && typeof api.openNotebook === "function") { + await api.openNotebook(notebookUri, envPath); + return; + } + } + + // Fallback: open without pre-selecting a kernel. await vscode.commands.executeCommand( "vscode.openWith", notebookUri, diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 401c0acaf60..dfde9d69f5c 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -99,6 +99,31 @@ export class EnvironmentManager { return env !== undefined; } + /** + * Return the `{ id, path }` for the course's Python environment, suitable + * for passing to the Jupyter extension's `openNotebook` API. + * Returns `undefined` when no environment has been resolved. + */ + async getEnvironmentPath( + courseRoot: vscode.Uri, + ): Promise<{ id: string; path: string } | undefined> { + if (!this.supported) { + return undefined; + } + const api = await this.pythonEnvironmentsApi(); + if (!api) { + return undefined; + } + const env = await this.findEnvironment(api, courseRoot); + if (!env) { + return undefined; + } + return { + id: env.envId.id, + path: env.environmentPath.fsPath, + }; + } + /** * Per-module import report for the course environment. Each entry is * `true` when that module imports successfully. Missing environment yields diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index ef1d9d489dc..1977d1148f2 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -600,6 +600,22 @@ export class LearningService { await this.ensureEnvironment(this.activeCourse, { force: true }); } + /** + * Return the `{ id, path }` for the active course's Python environment, + * suitable for passing to the Jupyter extension's `openNotebook` API. + * Returns `undefined` for Q# courses or when no environment exists. + */ + async getActiveCourseEnvironmentPath(): Promise< + { id: string; path: string } | undefined + > { + const course = this.activeCourse; + if (course.kind !== "python-notebook" || !course.sourceDir) { + return undefined; + } + const courseRoot = vscode.Uri.parse(course.sourceDir); + return this.environment.getEnvironmentPath(courseRoot); + } + /** * Apply a fix surfaced by {@link runEnvironmentCheck}. Centralizes the * mapping from an {@link EnvironmentCheckFix.kind} to a concrete action so From b335b575e1273291b0c72f8e8be31b8b68be318c Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 13:53:38 -0700 Subject: [PATCH 033/101] Rename functions --- source/vscode/src/learning/panel.ts | 5 ++--- source/vscode/src/learning/python/environment.ts | 2 +- source/vscode/src/learning/service.ts | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index 3f86a53c933..2075200f7b9 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -438,10 +438,9 @@ export class LessonPanelManager { // Try to open via the Jupyter extension's unstable API so the course's // Python environment is automatically set as the active kernel. - const envPath = await this.service.getActiveCourseEnvironmentPath(); + const envPath = await this.service.getJupyterEnvironmentPath(); if (envPath) { - const jupyter = - vscode.extensions.getExtension("ms-toolsai.jupyter"); + const jupyter = vscode.extensions.getExtension("ms-toolsai.jupyter"); const api = await jupyter?.activate(); if (api && typeof api.openNotebook === "function") { await api.openNotebook(notebookUri, envPath); diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index dfde9d69f5c..6d59cd51410 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -104,7 +104,7 @@ export class EnvironmentManager { * for passing to the Jupyter extension's `openNotebook` API. * Returns `undefined` when no environment has been resolved. */ - async getEnvironmentPath( + async getJupyterEnvironmentPath( courseRoot: vscode.Uri, ): Promise<{ id: string; path: string } | undefined> { if (!this.supported) { diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 1977d1148f2..36f806f8414 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -605,7 +605,7 @@ export class LearningService { * suitable for passing to the Jupyter extension's `openNotebook` API. * Returns `undefined` for Q# courses or when no environment exists. */ - async getActiveCourseEnvironmentPath(): Promise< + async getJupyterEnvironmentPath(): Promise< { id: string; path: string } | undefined > { const course = this.activeCourse; @@ -613,7 +613,7 @@ export class LearningService { return undefined; } const courseRoot = vscode.Uri.parse(course.sourceDir); - return this.environment.getEnvironmentPath(courseRoot); + return this.environment.getJupyterEnvironmentPath(courseRoot); } /** From bb7c4fc960d92b4240d841b6e1c30a2bdf63fc01 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 13:58:50 -0700 Subject: [PATCH 034/101] Add more error handling around unstable call --- source/vscode/src/learning/panel.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index 2075200f7b9..d6d66102639 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -7,6 +7,7 @@ * the learning feature. */ +import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { qsharpExtensionId } from "../common.js"; import { LEARNING_FILE, LEARNING_TREE_VIEW_ID } from "./constants.js"; @@ -438,14 +439,28 @@ export class LessonPanelManager { // Try to open via the Jupyter extension's unstable API so the course's // Python environment is automatically set as the active kernel. - const envPath = await this.service.getJupyterEnvironmentPath(); - if (envPath) { + + try { const jupyter = vscode.extensions.getExtension("ms-toolsai.jupyter"); const api = await jupyter?.activate(); if (api && typeof api.openNotebook === "function") { - await api.openNotebook(notebookUri, envPath); - return; + const envPath = await this.service.getJupyterEnvironmentPath(); + if (envPath) { + await api.openNotebook(notebookUri, envPath); + return; + } else { + log.info( + "Didn't find a course virtual environment to use in notebook", + ); + } } + log.warn( + "Jupyter openNotebook API is not available; falling back to generic open.", + ); + } catch (e) { + log.warn( + `Jupyter openNotebook API call failed: ${e}; falling back to generic open.`, + ); } // Fallback: open without pre-selecting a kernel. From a275008a62f074417157ef7873e14d1de18f9d84 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 14:27:17 -0700 Subject: [PATCH 035/101] Add diagnostics to the notebook toolbar --- source/vscode/package.json | 5 +++++ .../courses/circuit-diagrams-new/_check_env.py | 10 ++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/source/vscode/package.json b/source/vscode/package.json index 6f9228c5595..3f02e4cf11f 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -463,6 +463,11 @@ "command": "qsharp-vscode.learningNotebookHint", "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", "group": "navigation@100" + }, + { + "command": "qsharp-vscode.learningCheckEnvironment", + "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", + "group": "navigation@101" } ], "notebook/cell/title": [ diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py index 2ac8f59ed1b..8697cc4539f 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py @@ -52,20 +52,17 @@ def check(notebook_dir: str | Path | None = None) -> None: venv_python = _find_venv_python(expected_venv) if venv_exists else None if not venv_exists: - # TODO (acasey): update this to refer to the toolbar button when it exists results.append(("Course venv", f"{expected_venv} — not found", False)) errors.append( "The course virtual environment does not exist yet.
    " - "Run QDK Learning: Doctor from the Command Palette " - "(Ctrl+Shift+P / Cmd+Shift+P) " + "Click the Run Course Diagnostics button in the notebook toolbar " "and choose Set up environment." ) elif not venv_python: - # TODO (acasey): update this to refer to the toolbar button when it exists results.append(("Course venv", f"{expected_venv} — corrupt (no python)", False)) errors.append( "The course virtual environment exists but has no Python interpreter.
    " - "Run QDK Learning: Doctor from the Command Palette " + "Click the Run Course Diagnostics button in the notebook toolbar " "and choose Set up environment to recreate it." ) else: @@ -100,7 +97,8 @@ def check(notebook_dir: str | Path | None = None) -> None: errors.append( "Install missing packages by running this in a new cell, then re-run this one:" f"
      %pip install -r ../requirements.txt
    " - "Or run QDK Learning: Doctor to set up the full environment." + "Or click the Run Course Diagnostics button in the notebook toolbar " + "to set up the full environment." ) elif import_checks and in_course_venv: results.append(("Packages", ", ".join(import_checks), True)) From 94421b231d491f3dd4d9e84a3e23ef3d8a9c987a Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 14:36:09 -0700 Subject: [PATCH 036/101] Apply notebook hack to commands.ts --- source/vscode/src/learning/commands.ts | 30 +++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 1133adb8aaa..9f2a0f27d67 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { LessonPanelManager } from "./panel.js"; import type { LearningService } from "./service.js"; @@ -99,9 +100,36 @@ export function registerLearningCommands( node.activity.type === "exercise" ) { // TODO (acasey): is there a way to focus on a particular cell? (maybe goToExerciseByCellId?) - // TODO (acasey): reconcile with code for opening notebook in panel.ts const notebookUri = service.getCurrentCodeFileUri(); if (notebookUri) { + // Try to open via the Jupyter extension's unstable API so the + // course's Python environment is automatically set as the active + // kernel. + try { + const jupyter = + vscode.extensions.getExtension("ms-toolsai.jupyter"); + const api = await jupyter?.activate(); + if (api && typeof api.openNotebook === "function") { + const envPath = await service.getJupyterEnvironmentPath(); + if (envPath) { + await api.openNotebook(notebookUri, envPath); + return; + } else { + log.info( + "Didn't find a course virtual environment to use in notebook", + ); + } + } + log.warn( + "Jupyter openNotebook API is not available; falling back to generic open.", + ); + } catch (e) { + log.warn( + `Jupyter openNotebook API call failed: ${e}; falling back to generic open.`, + ); + } + + // Fallback: open without pre-selecting a kernel. await vscode.commands.executeCommand( "vscode.openWith", notebookUri, From cc8ed3af5dd948c74256cc212eaf5c7edbd4eb0e Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 14:37:49 -0700 Subject: [PATCH 037/101] Fix cell ID in _exercises.json --- .../courses/circuit-diagrams-new/01-intro/_exercises.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json index 1410315603e..cbf93159cd5 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json @@ -2,7 +2,7 @@ "exercises": [ { "id": "forty_two", - "cellId": "d9a84106", + "cellId": "db329ce6", "title": "Your first Q# expression", "description": "Implement the forty_two() function so it returns 42.", "hints": [ From ef6f919cf04e7e6e026f6a2128e4ac3bf891cf55 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 14:43:02 -0700 Subject: [PATCH 038/101] Remove hint from notebook toolbar --- source/vscode/package.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/source/vscode/package.json b/source/vscode/package.json index 3f02e4cf11f..2a4bccd7fa1 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -459,15 +459,10 @@ } ], "notebook/toolbar": [ - { - "command": "qsharp-vscode.learningNotebookHint", - "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", - "group": "navigation@100" - }, { "command": "qsharp-vscode.learningCheckEnvironment", "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", - "group": "navigation@101" + "group": "navigation@100" } ], "notebook/cell/title": [ From 8d119585df04d606b03457e98951d539ad45b745 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 14:58:33 -0700 Subject: [PATCH 039/101] Add TODO --- source/vscode/src/learning/service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 36f806f8414..ef9a9fe60a5 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -597,6 +597,7 @@ export class LearningService { /** Set up the environment for the currently-active course. */ async setupActiveEnvironment(): Promise { + // TODO (acasey): also set kernel, if possible await this.ensureEnvironment(this.activeCourse, { force: true }); } From 28f50f49539dd5245b27a060bb97421edac1d474 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 14:58:46 -0700 Subject: [PATCH 040/101] Auto save on cell execution --- source/vscode/src/learning/index.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index 8c42e49eb37..27d71a01e38 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -39,16 +39,26 @@ export function initLearning( ); context.subscriptions.push( vscode.workspace.onDidChangeNotebookDocument((e) => { - // TODO (acasey): auto-save? - // When a cell finishes executing (executionSummary changes), check - // if it corresponds to an exercise in the active python-notebook - // course and update focus. If execution succeeded, mark complete. + // When a cell finishes executing (executionSummary changes), auto-save + // the notebook, check if it corresponds to an exercise in the active + // python-notebook course and update focus. If execution succeeded, + // mark complete. if ( !learningService.initialized || learningService.getActiveCourseInfo().kind !== "python-notebook" ) { return; } + const hasExecutionChange = e.cellChanges.some( + (change) => change.executionSummary !== undefined, + ); + if (hasExecutionChange) { + // Moving between notebooks is clumsy when they're unsaved. Since this + // is a working copy we created on the user's behalf, we're free to + // auto-save. + void e.notebook.save(); + } + for (const change of e.cellChanges) { if (change.executionSummary !== undefined) { const cellId = change.cell.metadata?.id; From d268ccd3845f6d085a053ade149e3ec201a5ee47 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 14:59:00 -0700 Subject: [PATCH 041/101] Respect themese in exercise reports --- .../courses/circuit-diagrams-new/_course_lib.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py index 900462379e7..933c7929325 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py @@ -52,7 +52,10 @@ def _pass(message: str) -> None: display( HTML( '
    ' f"✅ {message}" "
    " @@ -65,7 +68,10 @@ def _fail(message: str) -> None: display( HTML( '
    ' f"❌ {message}" "
    " @@ -194,11 +200,13 @@ def complete_unit(required_exercises: list[str] | None = None) -> None: marker = Path(".qdk-unit-complete") marker.write_text(f"{unit_id}\n") - # TODO (acasey): dark mode display( HTML( '
    ' "🎉 Congratulations — you've completed this unit!" "
    " From fa53265f836a8febba79d787a71bb80a598611bc Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 15:06:03 -0700 Subject: [PATCH 042/101] Rename _exercises.json to exercises.json --- source/vscode/src/learning/dropInCourseProvider.ts | 10 +++++----- source/vscode/src/learning/types.d.ts | 4 ++-- .../01-intro/{_exercises.json => exercises.json} | 0 .../02-circuits/{_exercises.json => exercises.json} | 0 4 files changed, 7 insertions(+), 7 deletions(-) rename source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/{_exercises.json => exercises.json} (100%) rename source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/{_exercises.json => exercises.json} (100%) diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index f5fd919ff7f..ec5b5c53902 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -53,7 +53,7 @@ interface CourseLocation { * Loads "drop-in" courses authored as folders on disk. A course is a * folder containing a `course.json` manifest plus per-unit subfolders. * Each unit is a Python notebook (`*.ipynb`) with an `intro.md` for the - * lesson panel and optional exercise metadata in `_exercises.json`. + * lesson panel and optional exercise metadata in `exercises.json`. * * Course folders are discovered under `qdk-learning/courses/*` in the * workspace. Malformed courses are skipped with a warning rather than @@ -223,7 +223,7 @@ export class DropInCourseProvider implements CourseProvider { * opened by the user through the panel's "Open Notebook" action; the * extension does not parse or execute cells. * - * Exercise metadata (hints, solutions) is loaded from `_exercises.json` + * Exercise metadata (hints, solutions) is loaded from `exercises.json` * if present and attached to the returned unit for use by chat LM tools. */ private async parseNotebookUnit( @@ -277,9 +277,9 @@ export class DropInCourseProvider implements CourseProvider { } satisfies CatalogLesson); } - // Load exercise metadata from _exercises.json (optional). + // Load exercise metadata from exercises.json (optional). const exercisesJson = await tryReadText( - vscode.Uri.joinPath(unitDir, "_exercises.json"), + vscode.Uri.joinPath(unitDir, "exercises.json"), ); let notebookExercises: NotebookExerciseInfo[] | undefined; if (exercisesJson) { @@ -299,7 +299,7 @@ export class DropInCourseProvider implements CourseProvider { } } catch (e) { log.warn( - `Failed to parse _exercises.json in unit "${unit.id}": ${String(e)}`, // TODO (acasey): Include course name? + `Failed to parse exercises.json in unit "${unit.id}": ${String(e)}`, // TODO (acasey): Include course name? ); } } diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index f42cdd8032d..e520c5f652f 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -266,7 +266,7 @@ export interface CatalogLesson { export type CatalogActivity = CatalogExercise | CatalogLesson; /** - * Exercise metadata loaded from a per-unit `_exercises.json` sidecar + * Exercise metadata loaded from a per-unit `exercises.json` sidecar * (python-notebook courses). Provides hints, solutions, and descriptions * for the chat LM tools without requiring cell parsing or execution. */ @@ -287,7 +287,7 @@ export interface CatalogUnit { activities: CatalogActivity[]; /** * Exercise metadata for python-notebook courses, loaded from - * `_exercises.json`. Used by chat LM tools for hints/solutions. + * `exercises.json`. Used by chat LM tools for hints/solutions. */ notebookExercises?: NotebookExerciseInfo[]; /** diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/exercises.json similarity index 100% rename from source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_exercises.json rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/exercises.json diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/exercises.json similarity index 100% rename from source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_exercises.json rename to source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/exercises.json From e640a0d2f76810988d40454d89942f49edb0cfb8 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 16:07:22 -0700 Subject: [PATCH 043/101] Navigate to particular exercise from tree view --- source/vscode/src/learning/commands.ts | 59 ++++++++++++++++++++------ source/vscode/src/learning/service.ts | 13 ++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 9f2a0f27d67..bcf830f4a7c 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -99,9 +99,11 @@ export function registerLearningCommands( node.kind === "activity" && node.activity.type === "exercise" ) { - // TODO (acasey): is there a way to focus on a particular cell? (maybe goToExerciseByCellId?) const notebookUri = service.getCurrentCodeFileUri(); if (notebookUri) { + const cellId = service.getCurrentExerciseCellId(); + let opened = false; + // Try to open via the Jupyter extension's unstable API so the // course's Python environment is automatically set as the active // kernel. @@ -113,29 +115,37 @@ export function registerLearningCommands( const envPath = await service.getJupyterEnvironmentPath(); if (envPath) { await api.openNotebook(notebookUri, envPath); - return; + opened = true; } else { log.info( "Didn't find a course virtual environment to use in notebook", ); } } - log.warn( - "Jupyter openNotebook API is not available; falling back to generic open.", - ); + if (!opened) { + log.warn( + "Jupyter openNotebook API is not available; falling back to generic open.", + ); + } } catch (e) { log.warn( `Jupyter openNotebook API call failed: ${e}; falling back to generic open.`, ); } - // Fallback: open without pre-selecting a kernel. - await vscode.commands.executeCommand( - "vscode.openWith", - notebookUri, - "jupyter-notebook", - { viewColumn: vscode.ViewColumn.Active, preview: false }, - ); + if (!opened) { + // Fallback: open without pre-selecting a kernel. + await vscode.commands.executeCommand( + "vscode.openWith", + notebookUri, + "jupyter-notebook", + { viewColumn: vscode.ViewColumn.Active, preview: false }, + ); + } + + if (cellId) { + revealNotebookCell(notebookUri, cellId); + } return; } } @@ -238,6 +248,31 @@ export function registerLearningCommands( ); } +/** + * Select and scroll to the cell with the given stable ID in an already-open + * notebook. No-op if the notebook isn't visible or the cell can't be found. + */ +function revealNotebookCell(notebookUri: vscode.Uri, cellId: string): void { + const uriStr = notebookUri.toString(); + const editor = vscode.window.visibleNotebookEditors.find( + (e) => e.notebook.uri.toString() === uriStr, + ); + if (!editor) { + log.warn(`Notebook editor not found for ${uriStr}; can't reveal cell.`); + return; + } + const cell = editor.notebook + .getCells() + .find((c) => c.metadata?.id === cellId); + if (!cell) { + log.warn(`Cell ${cellId} not found in ${uriStr}; can't reveal it.`); + return; + } + const range = new vscode.NotebookRange(cell.index, cell.index + 1); + editor.selection = range; + editor.revealRange(range, vscode.NotebookEditorRevealType.AtTop); +} + function nodeToTitle(node: LearningProgressNode): string { switch (node.kind) { case "course": diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index ef9a9fe60a5..9267e334b13 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -543,6 +543,19 @@ export class LearningService { return ids; } + /** + * The notebook cell ID backing the current activity — the inverse of + * {@link goToExerciseByCellId}. `undefined` when the course isn't a + * python-notebook course or the activity has no associated cell. + */ + getCurrentExerciseCellId(): string | undefined { + if (this.activeCourse.kind !== "python-notebook") { + return undefined; + } + const { unit, activity } = this.findCurrentActivity(); + return unit.notebookExercises?.find((e) => e.id === activity.id)?.cellId; + } + /** Enumerate all available courses (loaded or not). */ async getCourses(): Promise { return this.requireWorkspace().registry.listCourses(); From 48fd8744a662a41646b2be59bd6f2ff4e7f31c89 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 16:10:35 -0700 Subject: [PATCH 044/101] Improve scrolling --- source/vscode/src/learning/commands.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index bcf830f4a7c..02dc80a2556 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -249,8 +249,12 @@ export function registerLearningCommands( } /** - * Select and scroll to the cell with the given stable ID in an already-open - * notebook. No-op if the notebook isn't visible or the cell can't be found. + * Select the cell with the given stable ID in an already-open notebook and + * scroll it into view. When the cell is immediately preceded by a markdown + * cell — typically the exercise's instructions — that cell is scrolled to + * instead, so the learner sees the prompt and not just the code. + * + * No-op if the notebook isn't visible or the cell can't be found. */ function revealNotebookCell(notebookUri: vscode.Uri, cellId: string): void { const uriStr = notebookUri.toString(); @@ -268,9 +272,21 @@ function revealNotebookCell(notebookUri: vscode.Uri, cellId: string): void { log.warn(`Cell ${cellId} not found in ${uriStr}; can't reveal it.`); return; } - const range = new vscode.NotebookRange(cell.index, cell.index + 1); - editor.selection = range; - editor.revealRange(range, vscode.NotebookEditorRevealType.AtTop); + + // The selection stays on the exercise cell — only the scroll target + // widens to include the preceding prompt. + editor.selection = new vscode.NotebookRange(cell.index, cell.index + 1); + + const previous = + cell.index > 0 ? editor.notebook.cellAt(cell.index - 1) : undefined; + const revealStart = + previous?.kind === vscode.NotebookCellKind.Markup + ? previous.index + : cell.index; + editor.revealRange( + new vscode.NotebookRange(revealStart, cell.index + 1), + vscode.NotebookEditorRevealType.Default, + ); } function nodeToTitle(node: LearningProgressNode): string { From 7ca6960e38bf41e7152ef8ee5f284d2f3c0bfb9f Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 16:51:50 -0700 Subject: [PATCH 045/101] First cut at hiding the panel for notebook courses --- source/vscode/package.json | 23 +- source/vscode/src/learning/commands.ts | 147 ++++++----- source/vscode/src/learning/constants.ts | 10 + .../src/learning/dropInCourseProvider.ts | 51 +--- source/vscode/src/learning/index.ts | 37 ++- source/vscode/src/learning/panel.ts | 159 +++--------- .../vscode/src/learning/progressTreeView.ts | 14 +- .../src/learning/python/pythonRunner.ts | 3 +- source/vscode/src/learning/service.ts | 240 ++++-------------- source/vscode/src/learning/types.d.ts | 5 +- source/vscode/src/telemetry.ts | 2 +- .../circuit-diagrams-new/01-intro/intro.ipynb | 12 +- .../circuit-diagrams-new/01-intro/intro.md | 24 -- .../02-circuits/circuits.ipynb | 14 +- .../circuit-diagrams-new/02-circuits/intro.md | 25 -- 15 files changed, 282 insertions(+), 484 deletions(-) delete mode 100644 source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md delete mode 100644 source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/intro.md diff --git a/source/vscode/package.json b/source/vscode/package.json index 2a4bccd7fa1..8ada835a49c 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -326,6 +326,10 @@ "command": "qsharp-vscode.learningResetExercise", "when": "false" }, + { + "command": "qsharp-vscode.learningResetUnit", + "when": "false" + }, { "command": "qsharp-vscode.learningShowActivity", "when": "false" @@ -409,6 +413,10 @@ "command": "qsharp-vscode.learningCheckEnvironment", "when": "view == qsharp-vscode.learningTree && viewItem == coursePython" }, + { + "command": "qsharp-vscode.learningResetUnit", + "when": "view == qsharp-vscode.learningTree && viewItem == unitPython" + }, { "command": "qsharp-vscode.workspaceOpenPortal", "group": "inline", @@ -455,14 +463,19 @@ { "command": "qsharp-vscode.learningAskInChat", "group": "inline", - "when": "view == qsharp-vscode.learningTree && (viewItem == continue || viewItem == unit || viewItem == lesson || viewItem == exercise || viewItem == example)" + "when": "view == qsharp-vscode.learningTree && (viewItem == continue || viewItem == unit || viewItem == unitPython || viewItem == lesson || viewItem == exercise || viewItem == example)" } ], "notebook/toolbar": [ { "command": "qsharp-vscode.learningCheckEnvironment", - "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", + "when": "qsharp-vscode.learningNotebookActive", "group": "navigation@100" + }, + { + "command": "qsharp-vscode.learningResetUnit", + "when": "qsharp-vscode.learningNotebookActive", + "group": "navigation@110" } ], "notebook/cell/title": [ @@ -746,6 +759,12 @@ "category": "QDK Learning", "icon": "$(discard)" }, + { + "command": "qsharp-vscode.learningResetUnit", + "title": "Reset Unit", + "category": "QDK Learning", + "icon": "$(discard)" + }, { "command": "qsharp-vscode.learningShowActivity", "title": "Show Current Activity", diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 02dc80a2556..277dc88cc39 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -48,6 +48,36 @@ export function registerLearningCommands( }, ), + vscode.commands.registerCommand( + "qsharp-vscode.learningResetUnit", + async (node?: LearningProgressNode) => { + if (!service.initialized) { + return; + } + + // Invoked from the tree, the target unit may not be the current one. + const location = node ? nodeToLocation(node) : undefined; + if (location) { + if (location.courseId !== service.getActiveCourseId()) { + await service.switchCourse(location.courseId, "tree"); + } + await service.goTo(location, "tree"); + } + + const confirmed = await vscode.window.showWarningMessage( + "Reset this unit to the original notebook? Your current work will be lost.", + { modal: true }, + "Reset", + ); + if (confirmed !== "Reset") { + return; + } + + await service.resetExercise(); + vscode.window.showInformationMessage("Unit has been reset."); + }, + ), + // Progress tree commands vscode.commands.registerCommand( @@ -92,62 +122,11 @@ export function registerLearningCommands( await service.goTo(location, "tree"); - // For python-notebook exercise activities, open the notebook - // directly instead of showing the lesson panel. - if ( - service.getActiveCourseInfo().kind === "python-notebook" && - node.kind === "activity" && - node.activity.type === "exercise" - ) { - const notebookUri = service.getCurrentCodeFileUri(); - if (notebookUri) { - const cellId = service.getCurrentExerciseCellId(); - let opened = false; - - // Try to open via the Jupyter extension's unstable API so the - // course's Python environment is automatically set as the active - // kernel. - try { - const jupyter = - vscode.extensions.getExtension("ms-toolsai.jupyter"); - const api = await jupyter?.activate(); - if (api && typeof api.openNotebook === "function") { - const envPath = await service.getJupyterEnvironmentPath(); - if (envPath) { - await api.openNotebook(notebookUri, envPath); - opened = true; - } else { - log.info( - "Didn't find a course virtual environment to use in notebook", - ); - } - } - if (!opened) { - log.warn( - "Jupyter openNotebook API is not available; falling back to generic open.", - ); - } - } catch (e) { - log.warn( - `Jupyter openNotebook API call failed: ${e}; falling back to generic open.`, - ); - } - - if (!opened) { - // Fallback: open without pre-selecting a kernel. - await vscode.commands.executeCommand( - "vscode.openWith", - notebookUri, - "jupyter-notebook", - { viewColumn: vscode.ViewColumn.Active, preview: false }, - ); - } - - if (cellId) { - revealNotebookCell(notebookUri, cellId); - } - return; - } + // python-notebook courses don't use the lesson panel — the notebook + // is the primary surface, so open it directly. + if (service.getActiveCourseInfo().kind === "python-notebook") { + await openCourseNotebook(service); + return; } await panelManager.show(); @@ -237,7 +216,7 @@ export function registerLearningCommands( // Navigate to the exercise so the service state matches. if (cellId) { - await service.goToExerciseByCellId(cellId, "panel"); + await service.goToExerciseByCellId(cellId, "notebook"); } await vscode.commands.executeCommand("workbench.action.chat.open", { @@ -248,6 +227,60 @@ export function registerLearningCommands( ); } +/** + * Open the current unit's notebook working copy, pre-selecting the course's + * Python environment as the active kernel, and reveal the current exercise + * cell when there is one. + */ +async function openCourseNotebook(service: LearningService): Promise { + const notebookUri = service.getCurrentCodeFileUri(); + if (!notebookUri) { + log.warn("No notebook associated with the current position."); + return; + } + const cellId = service.getCurrentExerciseCellId(); + let opened = false; + + // Try to open via the Jupyter extension's unstable API so the course's + // Python environment is automatically set as the active kernel. + try { + const jupyter = vscode.extensions.getExtension("ms-toolsai.jupyter"); + const api = await jupyter?.activate(); + if (api && typeof api.openNotebook === "function") { + const envPath = await service.getJupyterEnvironmentPath(); + if (envPath) { + await api.openNotebook(notebookUri, envPath); + opened = true; + } else { + log.info("Didn't find a course virtual environment to use in notebook"); + } + } + if (!opened) { + log.warn( + "Jupyter openNotebook API is not available; falling back to generic open.", + ); + } + } catch (e) { + log.warn( + `Jupyter openNotebook API call failed: ${e}; falling back to generic open.`, + ); + } + + if (!opened) { + // Fallback: open without pre-selecting a kernel. + await vscode.commands.executeCommand( + "vscode.openWith", + notebookUri, + "jupyter-notebook", + { viewColumn: vscode.ViewColumn.Active, preview: false }, + ); + } + + if (cellId) { + revealNotebookCell(notebookUri, cellId); + } +} + /** * Select the cell with the given stable ID in an already-open notebook and * scroll it into view. When the cell is immediately preceded by a markdown diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts index 5a66686aead..987626e58dd 100644 --- a/source/vscode/src/learning/constants.ts +++ b/source/vscode/src/learning/constants.ts @@ -23,6 +23,16 @@ export const COURSE_README_FILE = "README.md"; export const LEARNING_WORKSPACE_DETECTED_CONTEXT = "qsharp-vscode.learningWorkspaceDetected"; +/** Suffix of the learner-editable working copy of a course notebook. */ +export const WORKBOOK_SUFFIX = ".workbook.ipynb"; + +/** + * Context key set while the active notebook editor is a course workbook. + * Scopes notebook toolbar actions to learning content. + */ +export const LEARNING_NOTEBOOK_ACTIVE_CONTEXT = + "qsharp-vscode.learningNotebookActive"; + /** Course ID for the built-in Quantum Katas. */ export const KATAS_COURSE_ID = "katas"; diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index ec5b5c53902..5fffec74926 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -10,13 +10,13 @@ import { COURSE_README_FILE, LEARNING_COURSES_SUBDIR, LEARNING_WORKSPACE_FOLDER, + WORKBOOK_SUFFIX, } from "./constants.js"; import type { CourseProvider } from "./courseProvider.js"; import type { CatalogActivity, CatalogCourse, CatalogExercise, - CatalogLesson, CatalogUnit, CourseDescriptor, CourseEnvironment, @@ -218,10 +218,9 @@ export class DropInCourseProvider implements CourseProvider { } /** - * Parse a `python-notebook` unit. Each unit produces a single text-lesson - * activity from `intro.md` in the unit dir. The notebook itself is - * opened by the user through the panel's "Open Notebook" action; the - * extension does not parse or execute cells. + * Parse a `python-notebook` unit. The notebook itself carries the unit's + * narrative content and is opened directly by the user; the extension does + * not parse or execute cells. * * Exercise metadata (hints, solutions) is loaded from `exercises.json` * if present and attached to the returned unit for use by chat LM tools. @@ -243,7 +242,7 @@ export class DropInCourseProvider implements CourseProvider { (e) => e.type === vscode.FileType.File && e.name.toLowerCase().endsWith(".ipynb") && - !e.name.toLowerCase().endsWith(".workbook.ipynb"), // TODO (acasey): constant for .workbook + !e.name.toLowerCase().endsWith(WORKBOOK_SUFFIX), ) .sort((a, b) => a.name.localeCompare(b.name))[0]; // TODO (acasey): log finding multiple if (!notebookEntry) { @@ -255,27 +254,7 @@ export class DropInCourseProvider implements CourseProvider { const notebookRel = `${unit.dir}/${notebookEntry.name}`; - // Read intro.md for the lesson panel content. - const introContent = - (await tryReadText(vscode.Uri.joinPath(unitDir, "intro.md"))) ?? ""; - const activities: CatalogActivity[] = []; - if (introContent.length > 0) { - activities.push({ - type: "lesson", - id: "intro", - title: firstHeading(introContent) ?? humanize(unit.id), - content: introContent, - } satisfies CatalogLesson); - } else { - // Even without intro.md, emit a minimal lesson so navigation works. - activities.push({ - type: "lesson", - id: "intro", - title: unit.title, - content: `Open the notebook to begin this unit.`, - } satisfies CatalogLesson); - } // Load exercise metadata from exercises.json (optional). const exercisesJson = await tryReadText( @@ -407,23 +386,3 @@ async function uriExists(uri: vscode.Uri): Promise { return false; } } - -// ─── Text helpers ─── - -// TODO (acasey): do we need this level of support? Can we just insist on metadata? - -/** First markdown ATX heading (`# Title`) in the text, if any. */ -function firstHeading(markdown: string): string | undefined { - const match = markdown.match(/^#{1,6}\s+(.+?)\s*$/m); - return match ? match[1].trim() : undefined; -} - -/** Turn a file/dir slug into a human-readable title. */ -function humanize(slug: string): string { - return slug - .replace(/^\d+[-_.\s]*/, "") - .split(/[-_\s]+/) - .filter((w) => w.length > 0) - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" "); -} diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index 27d71a01e38..9cc3a87557e 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -7,6 +7,10 @@ import { exerciseDocumentSelector, } from "./codeLens.js"; import { registerLearningCommands } from "./commands.js"; +import { + LEARNING_NOTEBOOK_ACTIVE_CONTEXT, + WORKBOOK_SUFFIX, +} from "./constants.js"; import { LessonPanelManager, registerLessonPanelSerializer } from "./panel.js"; import { createNotebookCellStatusBarProvider } from "./notebookCellStatusBar.js"; import { registerLearningProgressView } from "./progressTreeView.js"; @@ -65,7 +69,7 @@ export function initLearning( if (typeof cellId !== "string") { continue; } - void learningService.goToExerciseByCellId(cellId, "panel"); + void learningService.goToExerciseByCellId(cellId, "notebook"); if (change.executionSummary.success) { void learningService.markExerciseCompleteByCellId(cellId); } @@ -77,9 +81,40 @@ export function initLearning( registerLearningWelcomeView(context, learningService); registerLearningCommands(context, learningService, panelManager); registerLessonPanelSerializer(context, panelManager); + registerNotebookContextKey(context, learningService); return learningService; } +/** + * Keep {@link LEARNING_NOTEBOOK_ACTIVE_CONTEXT} in sync with the active + * notebook editor so notebook toolbar actions only appear on course + * workbooks, not on every Jupyter notebook the user has open. + */ +function registerNotebookContextKey( + context: vscode.ExtensionContext, + service: LearningService, +): void { + const sync = (editor: vscode.NotebookEditor | undefined) => { + let isCourseNotebook = false; + if (editor && service.initialized) { + const uri = editor.notebook.uri.toString(); + isCourseNotebook = + uri.startsWith(service.learningContentRoot.toString()) && + uri.endsWith(WORKBOOK_SUFFIX); + } + void vscode.commands.executeCommand( + "setContext", + LEARNING_NOTEBOOK_ACTIVE_CONTEXT, + isCourseNotebook, + ); + }; + + context.subscriptions.push( + vscode.window.onDidChangeActiveNotebookEditor(sync), + ); + sync(vscode.window.activeNotebookEditor); +} + export type { CourseDescriptor, CourseKind, diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index d6d66102639..cd5d0fe3faf 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -7,7 +7,6 @@ * the learning feature. */ -import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { qsharpExtensionId } from "../common.js"; import { LEARNING_FILE, LEARNING_TREE_VIEW_ID } from "./constants.js"; @@ -48,7 +47,11 @@ export class LessonPanelManager { private readonly service: LearningService, ) {} - /** True when the active course is a python-notebook course. */ + /** + * True when the active course is a python-notebook course. Those courses + * use the notebook itself as the primary surface, so the lesson panel is + * never shown for them. + */ private get isPythonNotebook(): boolean { return ( this.service.initialized && @@ -58,13 +61,11 @@ export class LessonPanelManager { /** * Show or create the Lesson panel. + * + * No-op for python-notebook courses — the notebook is the primary surface + * there, so there is nothing for the panel to add. */ async show(): Promise { - if (this.panel) { - this.panel.reveal(vscode.ViewColumn.One); - return; - } - const ok = await this.service.tryInitialize(); if (!ok) { vscode.window.showWarningMessage( @@ -73,6 +74,15 @@ export class LessonPanelManager { return; } + if (this.isPythonNotebook) { + return; + } + + if (this.panel) { + this.panel.reveal(vscode.ViewColumn.One); + return; + } + this.panel = vscode.window.createWebviewPanel( "qsharp-lesson", "Lesson", @@ -111,10 +121,16 @@ export class LessonPanelManager { return; } + if (this.isPythonNotebook) { + // The active course no longer uses the panel — drop the serialized one. + panel.dispose(); + return; + } + this.panel = panel; // Restored panels predate any webview-option changes, so re-apply the - // current options (e.g. allowlisted command URIs) before re-rendering. + // current options before re-rendering. this.panel.webview.options = this.getWebviewOptions(); // Re-set HTML — webview resource URIs change across sessions. @@ -152,10 +168,16 @@ export class LessonPanelManager { // Listen for state changes from the service. this.disposables.push( this.service.onDidChangeState(() => { - if (this.panel) { - this.sendState(); - this.openCurrentCodeEditor().catch(() => {}); + if (!this.panel) { + return; } + if (this.isPythonNotebook) { + // Switched into a course that doesn't use the panel. + this.panel.dispose(); + return; + } + this.sendState(); + this.openCurrentCodeEditor().catch(() => {}); }), ); } @@ -164,7 +186,7 @@ export class LessonPanelManager { this.panel?.dispose(); // Close any lingering code editor tabs. if (this.service.initialized) { - this.closeStaleEditorTabs(undefined).catch(() => {}); + this.service.closeStaleEditorTabs(undefined).catch(() => {}); } for (const d of this.disposables) { d.dispose(); @@ -206,7 +228,6 @@ export class LessonPanelManager { /** * If the current position is an exercise or example, open the * corresponding .qs file in the secondary editor column. - * Closes any previously-opened code editor tabs that are no longer current. */ private async openCurrentCodeEditor(): Promise { if (!this.service.initialized) { @@ -214,9 +235,6 @@ export class LessonPanelManager { } const fileUri = this.service.getCurrentCodeFileUri(); - // Close stale editor tabs that don't match the current file. - await this.closeStaleEditorTabs(fileUri); - if (fileUri) { // Set a left/right two-column layout so the lesson panel stays in the // first editor group and the code file opens beside it in the second. @@ -231,33 +249,6 @@ export class LessonPanelManager { } } - /** - * Close any open editor tabs whose URI falls under the QDK Learning root - * that don't match {@link keepUri}. - * When {@link keepUri} is undefined, all code editor tabs are closed. - */ - private async closeStaleEditorTabs( - keepUri: vscode.Uri | undefined, - ): Promise { - const learningRoot = this.service.learningContentRoot.toString(); - const keepStr = keepUri?.toString(); - - const staleTabs: vscode.Tab[] = []; - for (const group of vscode.window.tabGroups.all) { - for (const tab of group.tabs) { - if (tab.input instanceof vscode.TabInputText) { - const tabUriStr = tab.input.uri.toString(); - if (tabUriStr.startsWith(learningRoot) && tabUriStr !== keepStr) { - staleTabs.push(tab); - } - } - } - } - if (staleTabs.length > 0) { - await vscode.window.tabGroups.close(staleTabs); - } - } - private sendResult( action: Action, result: ResultPayload, @@ -348,18 +339,12 @@ export class LessonPanelManager { try { switch (action) { case "next": { - // Activity-level navigation doesn't make sense in python notebooks - const result = this.isPythonNotebook - ? await this.service.nextUnit("panel") - : await this.service.next("panel"); + const result = await this.service.next("panel"); this.sendResult("next", result); break; } case "back": { - // Activity-level navigation doesn't make sense in python notebooks - const result = this.isPythonNotebook - ? await this.service.previousUnit("panel") - : await this.service.previous("panel"); + const result = await this.service.previous("panel"); this.sendResult("back", result); break; } @@ -374,9 +359,8 @@ export class LessonPanelManager { break; } case "reset": { - // TODO (acasey): is this text appropriate for all course flavors? const confirmed = await vscode.window.showWarningMessage( - "Reset this unit to the original notebook? Your current work will be lost.", + "Reset this exercise to the original placeholder code? Your current code will be lost.", { modal: true }, "Reset", ); @@ -386,10 +370,6 @@ export class LessonPanelManager { this.sendState(); break; } - case "open-notebook": { - await this.openCourseNotebook(); - break; - } default: this.sendError(`Unknown action: ${action}`); } @@ -418,68 +398,12 @@ export class LessonPanelManager { ); } - /** - * Open the current unit's notebook in the Jupyter editor (column 2), - * pre-selecting the course's Python environment as the active kernel. - */ - private async openCourseNotebook(): Promise { - if (!this.service.initialized) { - return; - } - const notebookUri = this.service.getCurrentCodeFileUri(); - if (!notebookUri) { - return; - } - // TODO (acasey): we can get rid of columns if we drop the web view panel - // Set a two-column layout: lesson panel left, notebook right. - await vscode.commands.executeCommand("vscode.setEditorLayout", { - orientation: 0, - groups: [{ size: 0.35 }, { size: 0.65 }], - }); - - // Try to open via the Jupyter extension's unstable API so the course's - // Python environment is automatically set as the active kernel. - - try { - const jupyter = vscode.extensions.getExtension("ms-toolsai.jupyter"); - const api = await jupyter?.activate(); - if (api && typeof api.openNotebook === "function") { - const envPath = await this.service.getJupyterEnvironmentPath(); - if (envPath) { - await api.openNotebook(notebookUri, envPath); - return; - } else { - log.info( - "Didn't find a course virtual environment to use in notebook", - ); - } - } - log.warn( - "Jupyter openNotebook API is not available; falling back to generic open.", - ); - } catch (e) { - log.warn( - `Jupyter openNotebook API call failed: ${e}; falling back to generic open.`, - ); - } - - // Fallback: open without pre-selecting a kernel. - await vscode.commands.executeCommand( - "vscode.openWith", - notebookUri, - "jupyter-notebook", - { viewColumn: vscode.ViewColumn.Two, preview: false }, - ); - } - /** * Webview options for the lesson panel. * - * `enableCommandUris` is restricted to an allowlist so author-supplied - * markdown (drop-in courses) can link to specific learning commands — e.g. - * a "Check my environment" button in a unit overview that runs the - * environment check — without granting the ability to invoke arbitrary VS - * Code commands. + * Command URIs are deliberately not enabled: the panel only renders + * built-in course content, so nothing needs to invoke VS Code commands + * from inside the webview. */ private getWebviewOptions(): vscode.WebviewPanelOptions & vscode.WebviewOptions { @@ -487,7 +411,6 @@ export class LessonPanelManager { enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true, - enableCommandUris: ["qsharp-vscode.learningCheckEnvironment"], // TODO (acasey): validate this localResourceRoots: [ vscode.Uri.joinPath(this.extensionUri, "out"), vscode.Uri.joinPath(this.extensionUri, "resources"), @@ -535,8 +458,6 @@ export class LessonPanelManager { private async checkSolutionAndSendResult( source?: TelemetrySource, ): Promise { - // TODO (acasey): why isn't this state okay? - // TODO (acasey): update checkSolution or other callers const { result } = await this.service.checkSolution(source); this.sendMessage({ command: "result", diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts index 01a33602b08..c1713b64640 100644 --- a/source/vscode/src/learning/progressTreeView.ts +++ b/source/vscode/src/learning/progressTreeView.ts @@ -131,7 +131,10 @@ class LearningProgressTreeProvider implements vscode.TreeDataProvider a.id !== "intro") - : node.unit.activities; - return activities.map((activity) => ({ + return node.unit.activities.map((activity) => ({ kind: "activity", courseId: node.courseId, unitId: node.unit.id, diff --git a/source/vscode/src/learning/python/pythonRunner.ts b/source/vscode/src/learning/python/pythonRunner.ts index b31aead912c..b1170d9a83d 100644 --- a/source/vscode/src/learning/python/pythonRunner.ts +++ b/source/vscode/src/learning/python/pythonRunner.ts @@ -3,6 +3,7 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; +import { WORKBOOK_SUFFIX } from "../constants.js"; import type { CatalogCourse } from "../types.js"; // TODO (acasey): rename this @@ -163,7 +164,7 @@ export class PythonCourseRunner { * (e.g. `01-intro/intro.ipynb` → `01-intro/intro.workbook.ipynb`). */ function toWorkbookRel(notebookRel: string): string { - return notebookRel.replace(/\.ipynb$/i, ".workbook.ipynb"); + return notebookRel.replace(/\.ipynb$/i, WORKBOOK_SUFFIX); } async function uriExists(uri: vscode.Uri): Promise { diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 9267e334b13..7fff67895e4 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -28,7 +28,6 @@ import type { CatalogActivity, CatalogUnit, CourseDescriptor, - CourseKind, CurrentActivity, EnvironmentCheckFix, EnvironmentCheckItem, @@ -146,7 +145,16 @@ export class LearningService { private _pythonRunner: PythonCourseRunner | undefined; private _environment: EnvironmentManager | undefined; - constructor(private readonly extensionUri: vscode.Uri) {} + constructor(private readonly extensionUri: vscode.Uri) { + // Navigating away from an activity leaves its file behind. Close those + // tabs here rather than in the lesson panel, which isn't shown for every + // course kind. + this._disposables.push( + this.onDidChangeState(() => { + void this.closeStaleEditorTabs(this.getCurrentCodeFileUri()); + }), + ); + } get initialized(): boolean { return this.workspace !== undefined; @@ -279,41 +287,18 @@ export class LearningService { } /** - * State snapshot tailored for the lesson webview panel. + * State snapshot for the lesson webview panel. * - * For python-notebook courses the panel always shows the unit-level - * summary (intro lesson) rather than drilling into a specific exercise. - * Other course kinds fall through to {@link getState}. + * python-notebook courses don't use the panel at all — the notebook is the + * primary surface there — so calling this for one is a programming error. */ getStateForPanel(): LearningState { - // TODO (acasey): might be moot if exercise-level navigation works? - if (this.activeCourse.kind !== "python-notebook") { - return this.getState(); + if (this.activeCourse.kind === "python-notebook") { + throw new Error( + "The lesson panel is not used for python-notebook courses.", + ); } - - const pos = this.position; - const unit = this.findUnit(pos.unitId); - const intro = unit.activities.find((a) => a.id === "intro")!; - - const introLocation: ActivityLocation = { - courseId: pos.courseId, - unitId: pos.unitId, - activityId: intro.id, - }; - - const position: CurrentActivity = { - location: introLocation, - unitTitle: unit.title, - activityTitle: unit.title, - content: this.resolveActivityContent(introLocation, unit, intro), - }; - - return { - course: this.getActiveCourseInfo(), - position, - actions: this.getAvailableActionsForPanel(unit), - progress: this.getProgress(), - }; + return this.getState(); } async next(source: TelemetrySource): Promise { @@ -357,74 +342,6 @@ export class LearningService { return { moved: true }; } - /** - * Navigate to the intro of the next unit. Used by the panel for - * python-notebook courses where navigation is unit-scoped. - */ - async nextUnit(source: TelemetrySource): Promise { - const ws = this.requireWorkspace(); - const course = this.activeCourse; - const currentUnitId = ws.progressData.position.unitId; - const idx = course.units.findIndex((u) => u.id === currentUnitId); - if (idx < 0 || idx >= course.units.length - 1) { - return { moved: false }; - } - const nextU = course.units[idx + 1]; - const firstActivity = nextU.activities[0]; - if (!firstActivity) { - return { moved: false }; - } - - // Auto-mark the intro lesson of the current unit complete. - const introLocation: ActivityLocation = { - courseId: course.id, - unitId: currentUnitId, - activityId: "intro", - }; - if (!this.isComplete(introLocation)) { - this.markComplete(introLocation); - } - - ws.progressData.position = { - courseId: course.id, - unitId: nextU.id, - activityId: firstActivity.id, - }; - await this.saveProgress(); - this._onDidChangeState.fire(this.getState()); - this.sendActivityActionTelemetry("navigate", source); - return { moved: true }; - } - - /** - * Navigate to the intro of the previous unit. Used by the panel for - * python-notebook courses where navigation is unit-scoped. - */ - async previousUnit(source: TelemetrySource): Promise { - const ws = this.requireWorkspace(); - const course = this.activeCourse; - const currentUnitId = ws.progressData.position.unitId; - const idx = course.units.findIndex((u) => u.id === currentUnitId); - if (idx <= 0) { - return { moved: false }; - } - const prevU = course.units[idx - 1]; - const firstActivity = prevU.activities[0]; - if (!firstActivity) { - return { moved: false }; - } - - ws.progressData.position = { - courseId: course.id, - unitId: prevU.id, - activityId: firstActivity.id, - }; - await this.saveProgress(); - this._onDidChangeState.fire(this.getState()); - this.sendActivityActionTelemetry("navigate", source); - return { moved: true }; - } - async goTo( location: { unitId: string; activityId?: string }, source?: TelemetrySource, @@ -1525,53 +1442,7 @@ export class LearningService { /** Builds the button groups shown in the webview toolbar for the current activity. */ private getAvailableActions(): ActionGroup[] { - const { activity, unit } = this.findCurrentActivity(); - - // Python-notebook courses: primary action is "Open Notebook" (or - // "Next" if the unit is already complete). - if (this.activeCourse.kind === "python-notebook" && unit.notebookRel) { - const isComplete = this.isComplete(this.position); - const primaryGroup: ActionGroup = isComplete - ? [{ key: "space", label: "Next", action: "next", primary: true }] - : [ - { - key: "space", - label: "Open Notebook", - action: "open-notebook", - primary: true, - codicon: "notebook", - }, - ]; - const extraGroups: ActionGroup[] = isComplete - ? [ - [ - { - key: "o", - label: "Open Notebook", - action: "open-notebook", - codicon: "notebook", - }, - { key: "r", label: "Reset", action: "reset" }, - ], - ] - : [ - [ - { - key: "h", - label: "Hint", - action: "hint-chat", - codicon: "sparkle", - }, - { key: "r", label: "Reset", action: "reset" }, - ], - ]; - const navGroup: ActionGroup = [ - { key: "b", label: "Back", action: "back" }, - ]; - return [primaryGroup, ...extraGroups, navGroup].filter( - (g) => g.length > 0, - ); - } + const { activity } = this.findCurrentActivity(); const primary = this.getPrimaryAction(); @@ -1638,51 +1509,38 @@ export class LearningService { } /** - * Actions for the panel in python-notebook courses. Checks whether - * the entire unit is complete (all exercises done) rather than a - * single activity. + * Close any open editor or notebook tabs under the QDK Learning root that + * don't match {@link keepUri}. When {@link keepUri} is undefined, all such + * tabs are closed. */ - private getAvailableActionsForPanel(unit: CatalogUnit): ActionGroup[] { - const course = this.activeCourse; - const unitComplete = unit.activities - .filter((a) => a.type === "exercise") - .every((a) => - this.isComplete({ - courseId: course.id, - unitId: unit.id, - activityId: a.id, - }), - ); - - const primaryGroup: ActionGroup = unitComplete - ? [{ key: "space", label: "Next", action: "next", primary: true }] - : [ - { - key: "space", - label: "Open Notebook", - action: "open-notebook", - primary: true, - codicon: "notebook", - }, - ]; - - const extraGroups: ActionGroup[] = unitComplete - ? [ - [ - { - key: "o", - label: "Open Notebook", - action: "open-notebook", - codicon: "notebook", - }, - { key: "r", label: "Reset", action: "reset" }, - ], - ] - : [[{ key: "r", label: "Reset", action: "reset" }]]; - - const navGroup: ActionGroup = [{ key: "b", label: "Back", action: "back" }]; + async closeStaleEditorTabs(keepUri: vscode.Uri | undefined): Promise { + if (!this.workspace) { + return; + } + const learningRoot = this.learningContentRoot.toString(); + const keepStr = keepUri?.toString(); - return [primaryGroup, ...extraGroups, navGroup].filter((g) => g.length > 0); + const staleTabs: vscode.Tab[] = []; + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input; + const tabUri = + input instanceof vscode.TabInputText || + input instanceof vscode.TabInputNotebook + ? input.uri + : undefined; + if (!tabUri) { + continue; + } + const tabUriStr = tabUri.toString(); + if (tabUriStr.startsWith(learningRoot) && tabUriStr !== keepStr) { + staleTabs.push(tab); + } + } + } + if (staleTabs.length > 0) { + await vscode.window.tabGroups.close(staleTabs); + } } /** Turns a catalog activity into the typed content payload (exercise, lesson-example, or lesson-text). */ diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index e520c5f652f..79be5c6c38f 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -13,7 +13,7 @@ // ─── Telemetry ─── -export type TelemetrySource = "panel" | "chat" | "tree"; +export type TelemetrySource = "panel" | "chat" | "tree" | "notebook"; // ─── Location ─── @@ -75,8 +75,7 @@ export type Action = | "check" | "reset" | "hint-chat" - | "explain-chat" - | "open-notebook"; + | "explain-chat"; export interface ActionBinding { /** Keyboard shortcut key (single character like "b", or "space"). */ diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index 196a5bc911e..5bfbd8ec496 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -344,7 +344,7 @@ type EventTypes = { properties: { action: "navigate" | "run" | "check" | "hint" | "solution" | "reset"; activityType: "lesson" | "exercise"; - source: "panel" | "chat" | "tree"; + source: "panel" | "chat" | "tree" | "notebook"; }; measurements: Empty; }; diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb index 2020ab088f5..7a64d8cfc8d 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb @@ -5,9 +5,15 @@ "id": "0928290d", "metadata": {}, "source": [ - "# Circuit Diagrams: Intro\n", + "# Getting Started\n", "\n", - "Welcome to the first unit of the QDK Circuit Diagrams course.\n", + "Welcome to the first unit of the **Generating Circuit Diagrams** course!\n", + "\n", + "In this unit you'll learn the basics of running Q# code from Python using the QDK. You'll:\n", + "\n", + "- Run your first Q# expression from a Python notebook\n", + "- Learn how the notebook exercises and verification work\n", + "- Practice editing and running cells\n", "\n", "## How to use this notebook\n", "\n", @@ -20,7 +26,7 @@ "\n", "Each cell builds on the ones above it, so work top-to-bottom and run each cell before moving on. You can re-run any cell as many times as you like.\n", "\n", - "Try the cell below to get started!" + "Try the cell below to get started!\n" ] }, { diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md deleted file mode 100644 index 3cf6c2b553b..00000000000 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.md +++ /dev/null @@ -1,24 +0,0 @@ -# Getting Started - -Welcome to the first unit of the **Generating Circuit Diagrams** course! - -In this unit you'll learn the basics of running Q# code from Python using the QDK. You'll: - -- Run your first Q# expression from a Python notebook -- Learn how the notebook exercises and verification work -- Practice editing and running cells - -## How it works - -1. Click **Open Notebook** below to open the unit notebook. -2. Work through the cells top-to-bottom — read the instructions, run examples, and fill in exercises. -3. When you've completed all exercises, run the final cell to mark the unit complete. -4. Come back here and click **Next** to continue to the next unit. - -## Before you start - -This course runs in its own Python environment. If the notebook's kernel -won't start, or the first cell reports a problem, set up and check your -environment here first: - -👉 [Check my environment](command:qsharp-vscode.learningCheckEnvironment) diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb index 87b1fa390a5..0e94b6c819d 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb @@ -5,11 +5,21 @@ "id": "3db5183b", "metadata": {}, "source": [ - "# Rendering Circuit Diagrams\n", + "# Circuit Diagrams\n", "\n", "In this unit you'll learn how to use the `circuit()` API and the `Circuit` widget to generate and display circuit diagrams from Q# operations.\n", "\n", - "We'll assume you're already familiar with Q# and quantum circuits. The focus here is on the **Python API** — what options are available and how they affect the rendered output.\n" + "You'll explore:\n", + "\n", + "- Rendering circuits with `qsharp.circuit()` and the `Circuit` widget\n", + "- Using `operation=` for qubit-only operations\n", + "- Controlling grouping with `group_by_scope`\n", + "- Handling measurement-based conditionals with `generation_method`\n", + "- Limiting output with `max_operations`\n", + "\n", + "We'll assume you're already familiar with Q# and quantum circuits. The focus here is on the **Python API** — what options are available and how they affect the rendered output.\n", + "\n", + "Work through the examples and exercises in order, then run the final cell to mark the unit complete.\n" ] }, { diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/intro.md b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/intro.md deleted file mode 100644 index 677e0767d74..00000000000 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/intro.md +++ /dev/null @@ -1,25 +0,0 @@ -# Circuit Diagrams - -In this unit you'll learn how to generate and display circuit diagrams from Q# operations using the Python API. - -You'll explore: - -- Rendering circuits with `qsharp.circuit()` and the `Circuit` widget -- Using `operation=` for qubit-only operations -- Controlling grouping with `group_by_scope` -- Handling measurement-based conditionals with `generation_method` -- Limiting output with `max_operations` - -## How it works - -1. Click **Open Notebook** below to open the unit notebook. -2. Work through the examples and exercises in order. -3. Run the final cell to mark the unit complete, then come back here. - -## Before you start - -This course runs in its own Python environment. If the notebook's kernel -won't start, or the first cell reports a problem, set up and check your -environment here first: - -👉 [Check my environment](command:qsharp-vscode.learningDoctor) From e17a4191db18be45a02c83b93b09c573d28efe4c Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 17:27:14 -0700 Subject: [PATCH 046/101] Use the readme as a splash screen --- source/vscode/src/learning/commands.ts | 15 +++++++++++++++ .../courses/circuit-diagrams-new/README.md | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 277dc88cc39..fc0c92d5c67 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -144,6 +144,21 @@ export function registerLearningCommands( return; } await service.switchCourse(courseId, "tree"); + + // python-notebook courses don't use the lesson panel. For a course + // that hasn't been started yet, show the README so there's something + // to read while the environment is set up in the background; + // otherwise pick up where the learner left off. + if (service.getActiveCourseInfo().kind === "python-notebook") { + if (service.getProgress().stats.completedActivities === 0) { + // TODO (acasey): close this once a notebook is open + await showCourseInfo(service, courseId); + } else { + await openCourseNotebook(service); + } + return; + } + await panelManager.show(); }, ), diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/README.md b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/README.md index bdd56f62442..668bfdabbe6 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/README.md +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/README.md @@ -3,6 +3,11 @@ A short, hands-on course that shows how to generate and visualize circuit diagrams from Q# operations using the QDK Python API in a Jupyter notebook. +## Getting started + +Pick a unit in the **Learning** tree view to open its notebook and start working +through it. Your progress is tracked there as you run the exercise cells. + ## What you'll learn - Run Q# code from Python with the `qdk` package. From 586eb12a0e545f69922279920dcc6aaab507f6d3 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 24 Jul 2026 17:34:57 -0700 Subject: [PATCH 047/101] Auto-save on open --- source/vscode/src/learning/commands.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index fc0c92d5c67..6ef65f3e950 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -294,6 +294,15 @@ async function openCourseNotebook(service: LearningService): Promise { if (cellId) { revealNotebookCell(notebookUri, cellId); } + + // The notebook may appear dirty immediately after opening (e.g. cell + // language adjustments). Save so the user starts with a clean state. + const doc = vscode.workspace.notebookDocuments.find( + (n) => n.uri.toString() === notebookUri.toString(), + ); + if (doc?.isDirty) { + await doc.save(); + } } /** From bd990b26653e3d1ace22e3d27a271f7a8fccb061 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sat, 25 Jul 2026 14:38:08 -0700 Subject: [PATCH 048/101] Delete tests --- .../test/suites/learning/index.browser.ts | 13 --- .../vscode/test/suites/learning/index.node.ts | 13 --- .../test/suites/learning/learning.test.ts | 103 ------------------ 3 files changed, 129 deletions(-) delete mode 100644 source/vscode/test/suites/learning/index.browser.ts delete mode 100644 source/vscode/test/suites/learning/index.node.ts delete mode 100644 source/vscode/test/suites/learning/learning.test.ts diff --git a/source/vscode/test/suites/learning/index.browser.ts b/source/vscode/test/suites/learning/index.browser.ts deleted file mode 100644 index fa243e23f93..00000000000 --- a/source/vscode/test/suites/learning/index.browser.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { runMochaTests } from "../runBrowser"; - -export function run(): Promise { - return runMochaTests(() => { - // We can't use any wildcards or dynamically discovered - // paths here since ESBuild needs these modules to be - // real paths on disk at bundling time. - require("./learning.test"); // eslint-disable-line @typescript-eslint/no-require-imports - }); -} diff --git a/source/vscode/test/suites/learning/index.node.ts b/source/vscode/test/suites/learning/index.node.ts deleted file mode 100644 index 5b518e57034..00000000000 --- a/source/vscode/test/suites/learning/index.node.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { runMochaTests } from "../runNode"; - -export async function run(): Promise { - await runMochaTests(() => { - // We can't use any wildcards or dynamically discovered - // paths here since ESBuild needs these modules to be - // real paths on disk at bundling time. - require("./learning.test"); // eslint-disable-line @typescript-eslint/no-require-imports - }); -} diff --git a/source/vscode/test/suites/learning/learning.test.ts b/source/vscode/test/suites/learning/learning.test.ts deleted file mode 100644 index ba37e024c2d..00000000000 --- a/source/vscode/test/suites/learning/learning.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { assert } from "chai"; -import { type ExtensionApi } from "../../../src/extension"; -import { activateExtension } from "../extensionUtils"; - -type LearningService = NonNullable; - -suite("QDK Learning multi-course", function suite() { - let service: LearningService; - - this.beforeAll(async function beforeAll() { - const api = await activateExtension(); - // The learning feature is desktop-only, so this suite is skipped in the - // web (browser) test host where no learning service is exposed. - // TODO (acasey): then why does index.browser.ts invoke this file? - if (!api.learning) { - this.skip(); - } - service = api.learning!; - const foundWorkspace = await service.tryInitialize({ - createIfMissing: true, - }); - if (!foundWorkspace) { - assert.fail( - "No workspace folder — the learning test-workspace fixture is missing", - ); - } - }); - - test("Katas is the default course", async () => { - const courses = await service.getCourses(); - assert.isTrue( - courses.some((c) => c.id === "katas"), - "the built-in Katas course should always be available", - ); - assert.equal(service.getActiveCourseId(), "katas"); - }); - - test("Drop-in python-notebook course is discovered", async function test() { - const courses = await service.getCourses(); - const descriptor = courses.find((c) => c.id === "circuit-diagrams"); - assert.ok(descriptor, "the fixture course should be discovered"); - assert.equal(descriptor!.kind, "python-notebook"); - }); - - test("Notebook unit parses into a lesson, example, and two tasks", async function test() { - await service.switchCourse("circuit-diagrams", "tree"); - try { - assert.equal(service.getActiveCourseId(), "circuit-diagrams"); - const units = service.listUnits(); - assert.equal(units.length, 2, "course should have two units"); - - const progress = service.getProgress(); - const activities = progress.units[1].activities; - const ids = activities.map((a) => a.id); - assert.include(ids, "cat_circuit"); - assert.include(ids, "flat_circuit"); - - const exercises = activities.filter((a) => a.type === "exercise"); - assert.equal(exercises.length, 2, "both tasks should become exercises"); - } finally { - await service.switchCourse("katas", "tree"); - } - }); - - test("Environment check returns a structured report", async function test() { - await service.switchCourse("circuit-diagrams", "tree"); - try { - const report = await service.runEnvironmentCheck(); - assert.equal(report.courseId, "circuit-diagrams"); - assert.isAbove( - report.checks.length, - 0, - "the report should contain checks", - ); - // Until the per-course environment is set up (or on a host without the - // tooling), the report should flag problems and offer a fix. - if (report.overallStatus !== "ok") { - assert.isTrue( - report.fixes.length > 0 || - report.checks.some((c) => c.status !== "ok"), - "a failing report should be actionable", - ); - } - } finally { - await service.switchCourse("katas", "tree"); - } - }); - - test("Katas course needs no environment (check passes)", async () => { - await service.switchCourse("katas", "tree"); - const report = await service.runEnvironmentCheck(); - assert.equal(report.courseId, "katas"); - assert.equal( - report.overallStatus, - "ok", - "Q# courses should pass diagnostics trivially", - ); - assert.isFalse(report.fixes.some((r) => r.kind === "setup")); - }); -}); From f46ca9fee8443abd0461afa1c003bfe06cd4f506 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sat, 25 Jul 2026 14:44:26 -0700 Subject: [PATCH 049/101] Re-open notebook after resetting --- source/vscode/src/learning/commands.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 6ef65f3e950..a7972ecece3 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -74,6 +74,7 @@ export function registerLearningCommands( } await service.resetExercise(); + await openCourseNotebook(service); vscode.window.showInformationMessage("Unit has been reset."); }, ), From 719f57bdc55d8ff9c52123a4cb5eea101c793600 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sat, 25 Jul 2026 14:55:05 -0700 Subject: [PATCH 050/101] Improve scrolling to locations indicated by tree --- source/vscode/src/learning/commands.ts | 62 ++++++++++++++++++++------ 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index a7972ecece3..b4c4f244c93 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -124,9 +124,14 @@ export function registerLearningCommands( await service.goTo(location, "tree"); // python-notebook courses don't use the lesson panel — the notebook - // is the primary surface, so open it directly. + // is the primary surface, so open it directly. Clicking a unit + // targets the unit as a whole (the position lands on its first + // activity), so start the learner at the top of the notebook rather + // than jumping straight to an exercise. if (service.getActiveCourseInfo().kind === "python-notebook") { - await openCourseNotebook(service); + await openCourseNotebook(service, { + reveal: node.kind === "unit" ? "top" : "exercise", + }); return; } @@ -245,10 +250,15 @@ export function registerLearningCommands( /** * Open the current unit's notebook working copy, pre-selecting the course's - * Python environment as the active kernel, and reveal the current exercise - * cell when there is one. + * Python environment as the active kernel. + * + * By default this reveals the current exercise cell; pass `reveal: "top"` to + * start at the beginning of the notebook instead. */ -async function openCourseNotebook(service: LearningService): Promise { +async function openCourseNotebook( + service: LearningService, + options?: { reveal?: "exercise" | "top" }, +): Promise { const notebookUri = service.getCurrentCodeFileUri(); if (!notebookUri) { log.warn("No notebook associated with the current position."); @@ -292,7 +302,9 @@ async function openCourseNotebook(service: LearningService): Promise { ); } - if (cellId) { + if (options?.reveal === "top") { + revealNotebookTop(notebookUri); + } else if (cellId) { revealNotebookCell(notebookUri, cellId); } @@ -315,19 +327,15 @@ async function openCourseNotebook(service: LearningService): Promise { * No-op if the notebook isn't visible or the cell can't be found. */ function revealNotebookCell(notebookUri: vscode.Uri, cellId: string): void { - const uriStr = notebookUri.toString(); - const editor = vscode.window.visibleNotebookEditors.find( - (e) => e.notebook.uri.toString() === uriStr, - ); + const editor = findNotebookEditor(notebookUri); if (!editor) { - log.warn(`Notebook editor not found for ${uriStr}; can't reveal cell.`); return; } const cell = editor.notebook .getCells() .find((c) => c.metadata?.id === cellId); if (!cell) { - log.warn(`Cell ${cellId} not found in ${uriStr}; can't reveal it.`); + log.warn(`Cell ${cellId} not found in ${notebookUri}; can't reveal it.`); return; } @@ -343,10 +351,38 @@ function revealNotebookCell(notebookUri: vscode.Uri, cellId: string): void { : cell.index; editor.revealRange( new vscode.NotebookRange(revealStart, cell.index + 1), - vscode.NotebookEditorRevealType.Default, + vscode.NotebookEditorRevealType.AtTop, ); } +/** + * Scroll an already-open notebook back to its first cell. Used when the + * learner opens a unit as a whole rather than a specific exercise. + */ +function revealNotebookTop(notebookUri: vscode.Uri): void { + const editor = findNotebookEditor(notebookUri); + if (!editor || editor.notebook.cellCount === 0) { + return; + } + const range = new vscode.NotebookRange(0, 1); + editor.selection = range; + editor.revealRange(range, vscode.NotebookEditorRevealType.AtTop); +} + +/** The visible editor showing the given notebook, if there is one. */ +function findNotebookEditor( + notebookUri: vscode.Uri, +): vscode.NotebookEditor | undefined { + const uriStr = notebookUri.toString(); + const editor = vscode.window.visibleNotebookEditors.find( + (e) => e.notebook.uri.toString() === uriStr, + ); + if (!editor) { + log.warn(`Notebook editor not found for ${uriStr}; can't scroll it.`); + } + return editor; +} + function nodeToTitle(node: LearningProgressNode): string { switch (node.kind) { case "course": From 0910c892593f6ba077ba5722e94179d1d7910e0f Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sat, 25 Jul 2026 15:01:04 -0700 Subject: [PATCH 051/101] Reset all exercises in unit --- source/vscode/src/learning/service.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 7fff67895e4..4a4106a552c 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1037,7 +1037,9 @@ export class LearningService { // may not exist } } - this.markIncomplete(this.requireWorkspace().progressData.position); + // Clear completion for every activity in the unit, not just the + // current one, since the whole unit was re-materialized. + this.markUnitIncomplete(this.activeCourse.id, unit); await this.saveProgress(); this._onDidChangeState.fire(this.getState()); if (source) { @@ -1824,6 +1826,17 @@ export class LearningService { delete this.requireWorkspace().progressData.completions[key]; } + /** Clear completion for every activity in the given unit. */ + private markUnitIncomplete(courseId: string, unit: CatalogUnit): void { + for (const activity of unit.activities) { + this.markIncomplete({ + courseId, + unitId: unit.id, + activityId: activity.id, + }); + } + } + private startWatcher(): void { if (this._progressFileWatcher) { return; From 09abe55c07fd62e98c90249dbcfa4f19e42034c4 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sat, 25 Jul 2026 15:21:02 -0700 Subject: [PATCH 052/101] Reopen after applying fixes --- source/vscode/src/learning/commands.ts | 48 ++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index b4c4f244c93..3c92feea8f5 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -548,7 +548,51 @@ async function runEnvironmentCheckCommand( return; } const fix = report.fixes.find((r) => r.label === choice); - if (fix) { - await service.applyEnvironmentCheckFix(fix); + if (!fix) { + return; + } + await service.applyEnvironmentCheckFix(fix); + + // These fixes change how the notebook binds to a kernel (creating the + // course environment, or installing the Python/Jupyter extensions), so + // close and re-open the notebook to pick up the new environment. + // Best-effort: a failure here shouldn't make the fix look like it failed. + if (fix.kind === "setup" || fix.kind === "install-extensions") { + try { + const notebookUri = service.getCurrentCodeFileUri(); + if (notebookUri) { + await closeNotebook(notebookUri); + } + await openCourseNotebook(service); + } catch (e) { + log.warn(`Failed to re-open the course notebook after a fix: ${e}`); + } + } +} + +/** + * Close every tab showing the given notebook, saving first if it has unsaved + * changes so no confirmation dialog blocks the close. No-op when the notebook + * isn't open. + */ +async function closeNotebook(notebookUri: vscode.Uri): Promise { + const uriStr = notebookUri.toString(); + + const doc = vscode.workspace.notebookDocuments.find( + (n) => n.uri.toString() === uriStr, + ); + if (doc?.isDirty) { + await doc.save(); + } + + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter( + (tab) => + tab.input instanceof vscode.TabInputNotebook && + tab.input.uri.toString() === uriStr, + ); + if (tabs.length > 0) { + await vscode.window.tabGroups.close(tabs); } } From b6aac8616878ca09650164952ab5752605cacca1 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sat, 25 Jul 2026 15:53:34 -0700 Subject: [PATCH 053/101] Drop the sentinel file for completion tracking --- source/vscode/src/learning/service.ts | 92 +------------------ .../circuit-diagrams-new/_course_lib.py | 13 +-- 2 files changed, 10 insertions(+), 95 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 4a4106a552c..57321f2ae52 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -138,7 +138,6 @@ export class LearningService { private _lastSnapshot: OverallProgress | undefined; private _progressFileWatcher: vscode.FileSystemWatcher | undefined; - private _sentinelWatcher: vscode.FileSystemWatcher | undefined; private _writingProgress = false; private _initPromise: Promise | undefined; private readonly _disposables: vscode.Disposable[] = []; @@ -242,7 +241,6 @@ export class LearningService { this._onDidChangeState.dispose(); this._onDidChangeProgress.dispose(); this._progressFileWatcher?.dispose(); - this.stopSentinelWatcher(); this._environment?.dispose(); for (const d of this._disposables) { d.dispose(); @@ -780,7 +778,6 @@ export class LearningService { } ws.progressData.position = this.firstIncompletePosition(course); await this.saveProgress(); - this.startSentinelWatcher(); const state = this.getState(); this._onDidChangeState.fire(state); if (source) { @@ -1023,20 +1020,6 @@ export class LearningService { } // Re-materialize the unit from source. await this.pythonRunner.rematerializeUnit(this.activeCourse, unit.id); - // Delete the sentinel file if present. - if (unit.notebookRel) { - const workingCopyUri = this.notebookFileUri(unit.notebookRel); - const sentinelUri = vscode.Uri.joinPath( - workingCopyUri, - "..", - ".qdk-unit-complete", - ); - try { - await vscode.workspace.fs.delete(sentinelUri); - } catch { - // may not exist - } - } // Clear completion for every activity in the unit, not just the // current one, since the whole unit was re-materialized. this.markUnitIncomplete(this.activeCourse.id, unit); @@ -1130,9 +1113,9 @@ export class LearningService { this.sendActivityActionTelemetry("check", source); } - // Python-notebook courses use in-notebook verification via - // complete_unit(). The extension detects completion via the sentinel - // file watcher, not through this method. + // Python-notebook courses verify in the notebook itself: running an + // exercise cell runs its checker, and the extension records completion + // from the cell's execution result rather than through this method. if (this.activeCourse.kind === "python-notebook") { return { result: { @@ -1140,8 +1123,8 @@ export class LearningService { messages: [], error: "This course uses native notebook execution. " + - "Run all cells in the notebook, including the final " + - "complete_unit() cell, to mark the unit complete.", + "Run the exercise cell in the notebook — each cell that " + + "succeeds marks that exercise complete.", }, state: this.getState(), }; @@ -1298,7 +1281,6 @@ export class LearningService { detected.learningContentRoot, ); this.startWatcher(); - this.startSentinelWatcher(); sendTelemetryEvent( EventType.LearningSessionStarted, { isFirstTime: "false" }, @@ -1329,7 +1311,6 @@ export class LearningService { this._writingProgress = false; } this.startWatcher(); - this.startSentinelWatcher(); sendTelemetryEvent( EventType.LearningSessionStarted, { isFirstTime: "true" }, @@ -1878,69 +1859,6 @@ export class LearningService { this._onDidChangeProgress.fire(this._lastSnapshot); } - // TODO (acasey): consider having a state.json file for the whole course instead of a bunch of little sentinel files - // This may require more coordination than is comfortable for content authors. - - /** - * Start watching for `.qdk-unit-complete` sentinel files in the active - * python-notebook course folder. When the notebook's `complete_unit()` writes this - * file, we mark the unit complete. - */ - private startSentinelWatcher(): void { - this.stopSentinelWatcher(); - const course = this.activeCourse; - if (course.kind !== "python-notebook" || !course.sourceDir) { - return; - } - const coursesDir = vscode.Uri.parse(course.sourceDir); - const pattern = new vscode.RelativePattern( - coursesDir, - "**/.qdk-unit-complete", - ); - this._sentinelWatcher = vscode.workspace.createFileSystemWatcher(pattern); - - const onSentinel = async (uri: vscode.Uri) => { - try { - const bytes = await vscode.workspace.fs.readFile(uri); - const unitId = new TextDecoder().decode(bytes).trim(); - if (!unitId) { - return; - } - // Find the unit and mark all its activities complete. - const unit = course.units.find((u) => u.id === unitId); - if (!unit || unit.activities.length === 0) { - return; - } - let changed = false; - for (const activity of unit.activities) { - const location: ActivityLocation = { - courseId: course.id, - unitId: unit.id, - activityId: activity.id, - }; - if (!this.isComplete(location)) { - this.markComplete(location); - changed = true; - } - } - if (changed) { - await this.saveProgress(); - this._onDidChangeState.fire(this.getState()); - } - } catch { - // sentinel may be transient or corrupt; ignore - } - }; - - this._sentinelWatcher.onDidCreate(onSentinel); - this._sentinelWatcher.onDidChange(onSentinel); - } - - private stopSentinelWatcher(): void { - this._sentinelWatcher?.dispose(); - this._sentinelWatcher = undefined; - } - /** * Close any open editor tabs whose URI matches the given notebook URI. */ diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py index 933c7929325..7a3a5c2c809 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py @@ -14,8 +14,6 @@ relevant visuals or output. """ -import re -from pathlib import Path from typing import Callable from IPython.display import HTML, display @@ -179,10 +177,14 @@ def checker(fn) -> None: def complete_unit(required_exercises: list[str] | None = None) -> None: - """Verify all exercises passed and write the unit-complete marker. + """Verify every exercise in the unit has passed and celebrate. When ``required_exercises`` is omitted, every exercise registered in this kernel session is required — i.e. all of the current unit's exercises. + + Progress is recorded per exercise as each exercise cell runs, so this is a + final check and a celebration rather than the thing that marks the unit + complete. """ if required_exercises is None: required_exercises = _registered @@ -195,11 +197,6 @@ def complete_unit(required_exercises: list[str] | None = None) -> None: "Run the exercise cells above first." ) - unit_id = re.sub(r"^\d+-", "", Path.cwd().name) - - marker = Path(".qdk-unit-complete") - marker.write_text(f"{unit_id}\n") - display( HTML( '
    None: on_success(result) return _register(name, checker) - - -# --------------------------------------------------------------------------- -# Unit completion -# --------------------------------------------------------------------------- - - -def complete_unit(required_exercises: list[str] | None = None) -> None: - """Verify every exercise in the unit has passed and celebrate. - - When ``required_exercises`` is omitted, every exercise registered in this - kernel session is required — i.e. all of the current unit's exercises. - - Progress is recorded per exercise as each exercise cell runs, so this is a - final check and a celebration rather than the thing that marks the unit - complete. - """ - if required_exercises is None: - required_exercises = _registered - missing = [e for e in required_exercises if e not in _passed] - if missing: - names = ", ".join(f"`{e}`" for e in missing) - # TODO (acasey): pretty report - raise AssertionError( - f"Not all exercises are complete. Missing: {names}. " - "Run the exercise cells above first." - ) - - display( - HTML( - '
    ' - "🎉 Congratulations — you've completed this unit!" - "
    " - ) - ) From ef7f9ce12301c625b472c3ed6b9292dbd7d58d11 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sat, 25 Jul 2026 19:50:09 -0700 Subject: [PATCH 055/101] Eliminate exercises.json in favor of authoring within the notebook --- .../src/learning/dropInCourseProvider.ts | 48 +-- .../vscode/src/learning/notebookExercises.ts | 349 ++++++++++++++++++ .../src/learning/python/pythonRunner.ts | 61 ++- source/vscode/src/learning/types.d.ts | 14 +- .../01-intro/exercises.json | 16 - .../circuit-diagrams-new/01-intro/intro.ipynb | 74 +++- .../02-circuits/circuits.ipynb | 116 +++++- .../02-circuits/exercises.json | 28 -- 8 files changed, 603 insertions(+), 103 deletions(-) create mode 100644 source/vscode/src/learning/notebookExercises.ts delete mode 100644 source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/exercises.json delete mode 100644 source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/exercises.json diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 5fffec74926..207e671652c 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -13,6 +13,7 @@ import { WORKBOOK_SUFFIX, } from "./constants.js"; import type { CourseProvider } from "./courseProvider.js"; +import { parseNotebookExercises } from "./notebookExercises.js"; import type { CatalogActivity, CatalogCourse, @@ -52,8 +53,8 @@ interface CourseLocation { /** * Loads "drop-in" courses authored as folders on disk. A course is a * folder containing a `course.json` manifest plus per-unit subfolders. - * Each unit is a Python notebook (`*.ipynb`) with an `intro.md` for the - * lesson panel and optional exercise metadata in `exercises.json`. + * Each unit is a Python notebook (`*.ipynb`) whose exercise metadata is + * marked up with cell tags. * * Course folders are discovered under `qdk-learning/courses/*` in the * workspace. Malformed courses are skipped with a warning rather than @@ -220,10 +221,11 @@ export class DropInCourseProvider implements CourseProvider { /** * Parse a `python-notebook` unit. The notebook itself carries the unit's * narrative content and is opened directly by the user; the extension does - * not parse or execute cells. + * not execute cells. * - * Exercise metadata (hints, solutions) is loaded from `exercises.json` - * if present and attached to the returned unit for use by chat LM tools. + * Exercise metadata (hints, solutions) is parsed from the authored + * notebook's cell tags and attached to the returned unit for use by chat + * LM tools. See `notebookExercises.ts` for the tag vocabulary. */ private async parseNotebookUnit( unitDir: vscode.Uri, @@ -256,32 +258,14 @@ export class DropInCourseProvider implements CourseProvider { const activities: CatalogActivity[] = []; - // Load exercise metadata from exercises.json (optional). - const exercisesJson = await tryReadText( - vscode.Uri.joinPath(unitDir, "exercises.json"), + // Exercise metadata lives in the authored notebook, marked up with cell + // tags. Read it here so it's available before materialization. + const notebookText = await tryReadText( + vscode.Uri.joinPath(unitDir, notebookEntry.name), ); - let notebookExercises: NotebookExerciseInfo[] | undefined; - if (exercisesJson) { - try { - const parsed = JSON.parse(exercisesJson) as { - exercises?: unknown; - }; - if (Array.isArray(parsed.exercises)) { - // TODO (acasey): validate the rest of the parsed input? - notebookExercises = parsed.exercises.filter( - (e): e is NotebookExerciseInfo => - !!e && - typeof e === "object" && - typeof (e as NotebookExerciseInfo).id === "string" && - typeof (e as NotebookExerciseInfo).cellId === "string", - ); - } - } catch (e) { - log.warn( - `Failed to parse exercises.json in unit "${unit.id}": ${String(e)}`, // TODO (acasey): Include course name? - ); - } - } + const notebookExercises = notebookText + ? parseNotebookExercises(notebookText, unit.id) + : undefined; // Surface each notebook exercise as a catalog activity so it appears // in the progress tree and can be navigated to. @@ -295,8 +279,8 @@ export class DropInCourseProvider implements CourseProvider { placeholderCode: "", sourceIds: [], hints: ex.hints, - solutionCodes: ex.solution ? [ex.solution] : [], // TODO (acasey): might want multiple solutions in python courses too - solutionExplanation: ex.solutionExplanation ?? "", + solutionCodes: ex.solutions, + solutionExplanation: ex.solutionExplanation, } satisfies CatalogExercise); } } diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts new file mode 100644 index 00000000000..f56f61ac862 --- /dev/null +++ b/source/vscode/src/learning/notebookExercises.ts @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import type { NotebookExerciseInfo } from "./types.js"; + +/** + * Authoring model for `python-notebook` course units. + * + * A unit's exercise metadata lives in the authored notebook itself, marked up + * with standard Jupyter cell tags (added via the built-in "Add Cell Tag" + * command). Authors never edit the raw `.ipynb` JSON and never write cell IDs + * by hand. + * + * | Tag | Cell kind | Meaning | + * | ------------- | --------- | --------------------------------------------- | + * | `exercise` | code | The cell the learner edits. | + * | `hint` | markdown | One hint. Multiple allowed, in document order.| + * | `solution` | code | A reference solution. Multiple allowed. | + * | `explanation` | markdown | Prose explanation of the solution. | + * + * `hint`, `solution` and `explanation` cells bind to the nearest preceding + * `exercise` cell, and are stripped from the learner's working copy during + * materialization (see {@link stripAuthoringCells}). + * + * The exercise id is the name of the `@exercise`-decorated function in the + * exercise cell. That name is the source of truth linking the notebook cell, + * this metadata, and the Python checker registered for it in `_unit.py`. + * + * This module works on raw notebook JSON rather than VS Code's notebook API + * because both course load and materialization happen with the file closed. + * The transform is delete-only, so no nbformat cells are ever constructed. + */ + +/** Tag marking the code cell a learner edits. */ +export const EXERCISE_TAG = "exercise"; + +/** Tags marking author-only cells, removed from the learner's working copy. */ +export const AUTHORING_TAGS = ["hint", "solution", "explanation"] as const; + +type AuthoringTag = (typeof AUTHORING_TAGS)[number]; + +/** The subset of an nbformat cell this module reads. */ +interface RawCell { + id?: unknown; + cell_type?: unknown; + source?: unknown; + metadata?: { tags?: unknown }; +} + +/** The subset of an nbformat notebook this module reads. */ +interface RawNotebook { + cells?: unknown; +} + +/** + * Parse the exercise metadata out of an authored notebook's JSON text. + * + * Malformed input never throws: problems are logged and the affected exercise + * or cell is skipped, so a bad notebook degrades to fewer exercises rather + * than an unloadable course. `unitLabel` identifies the unit in those logs. + */ +export function parseNotebookExercises( + text: string, + unitLabel: string, +): NotebookExerciseInfo[] { + const cells = readCells(text, unitLabel); + if (!cells) { + return []; + } + + const exercises: NotebookExerciseInfo[] = []; + const seenIds = new Set(); + + // The exercise most recently seen, and therefore the one that any + // subsequent authoring cells belong to. `undefined` until the first + // exercise cell, which makes leading authoring cells detectable as orphans. + let current: NotebookExerciseInfo | undefined; + + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + const tags = cellTags(cell); + + if (tags.includes(EXERCISE_TAG)) { + current = undefined; + + if (cellKind(cell) !== "code") { + log.warn( + `Learning: ignoring "${EXERCISE_TAG}" tag on a non-code cell in unit "${unitLabel}".`, + ); + continue; + } + + const id = exerciseId(cellSource(cell)); + if (!id) { + log.warn( + `Learning: skipping an "${EXERCISE_TAG}" cell in unit "${unitLabel}": ` + + "no @exercise-decorated function found. The exercise id comes from " + + "that function's name.", + ); + continue; + } + if (seenIds.has(id)) { + log.warn( + `Learning: skipping duplicate exercise "${id}" in unit "${unitLabel}".`, + ); + continue; + } + + const cellId = cellIdOf(cell); + if (!cellId) { + log.warn( + `Learning: skipping exercise "${id}" in unit "${unitLabel}": the cell has no id.`, + ); + continue; + } + + seenIds.add(id); + const { title, description } = precedingPrompt(cells, i, id); + current = { + id, + cellId, + title, + description, + hints: [], + solutions: [], + solutionExplanation: "", + }; + exercises.push(current); + continue; + } + + const authoringTag = AUTHORING_TAGS.find((t) => tags.includes(t)); + if (!authoringTag) { + continue; + } + + if (!current) { + log.warn( + `Learning: ignoring a "${authoringTag}" cell in unit "${unitLabel}": ` + + `it does not follow an "${EXERCISE_TAG}" cell.`, + ); + continue; + } + + if (!hasExpectedKind(cell, authoringTag)) { + log.warn( + `Learning: ignoring a "${authoringTag}" cell for exercise "${current.id}" ` + + `in unit "${unitLabel}": expected a ${expectedKind(authoringTag)} cell.`, + ); + continue; + } + + const source = cellSource(cell); + switch (authoringTag) { + case "hint": + current.hints.push(source); + break; + case "solution": + current.solutions.push(source); + break; + case "explanation": + if (current.solutionExplanation) { + log.warn( + `Learning: ignoring an extra "explanation" cell for exercise ` + + `"${current.id}" in unit "${unitLabel}".`, + ); + break; + } + current.solutionExplanation = source; + break; + } + } + + return exercises; +} + +/** + * Remove the author-only cells from a notebook's JSON text, returning the + * notebook the learner works in. + * + * Everything else — including cell ids and the `exercise` tag — is preserved + * verbatim, so metadata parsed from the authored notebook still resolves + * against the working copy. Returns `undefined` if the text isn't a notebook, + * leaving the caller to decide on a fallback. + */ +export function stripAuthoringCells( + text: string, + unitLabel: string, +): string | undefined { + let notebook: RawNotebook; + try { + notebook = JSON.parse(text) as RawNotebook; + } catch (e) { + log.warn( + `Learning: failed to parse the notebook for unit "${unitLabel}": ${String(e)}`, + ); + return undefined; + } + if (!Array.isArray(notebook.cells)) { + log.warn( + `Learning: the notebook for unit "${unitLabel}" has no "cells" array.`, + ); + return undefined; + } + + notebook.cells = (notebook.cells as RawCell[]).filter((cell) => { + const tags = cellTags(cell); + return !AUTHORING_TAGS.some((t) => tags.includes(t)); + }); + + // Match the ipynb serializer's formatting so the file stays diff-stable + // once VS Code starts saving it: one space of indent, trailing newline. + return `${JSON.stringify(notebook, undefined, 1)}\n`; +} + +// ─── Cell readers ─── + +function readCells(text: string, unitLabel: string): RawCell[] | undefined { + let notebook: RawNotebook; + try { + notebook = JSON.parse(text) as RawNotebook; + } catch (e) { + log.warn( + `Learning: failed to parse the notebook for unit "${unitLabel}": ${String(e)}`, + ); + return undefined; + } + if (!Array.isArray(notebook.cells)) { + log.warn( + `Learning: the notebook for unit "${unitLabel}" has no "cells" array.`, + ); + return undefined; + } + return (notebook.cells as unknown[]).filter( + (c): c is RawCell => !!c && typeof c === "object", + ); +} + +function cellTags(cell: RawCell): string[] { + const tags = cell.metadata?.tags; + return Array.isArray(tags) ? tags.filter((t) => typeof t === "string") : []; +} + +function cellKind(cell: RawCell): "code" | "markdown" | "other" { + const kind = cell.cell_type; + return kind === "code" || kind === "markdown" ? kind : "other"; +} + +function cellIdOf(cell: RawCell): string | undefined { + return typeof cell.id === "string" && cell.id.length > 0 + ? cell.id + : undefined; +} + +/** nbformat allows a cell's source to be a string or an array of lines. */ +function cellSource(cell: RawCell): string { + const source = cell.source; + if (typeof source === "string") { + return source; + } + if (Array.isArray(source)) { + return source.filter((line) => typeof line === "string").join(""); + } + return ""; +} + +function expectedKind(tag: AuthoringTag): "code" | "markdown" { + return tag === "solution" ? "code" : "markdown"; +} + +function hasExpectedKind(cell: RawCell, tag: AuthoringTag): boolean { + return cellKind(cell) === expectedKind(tag); +} + +// ─── Field derivation ─── + +/** + * The exercise id: the name of the `@exercise`-decorated function. The + * decorator may be applied bare or called, and other decorators may sit + * between it and the `def`. + */ +function exerciseId(source: string): string | undefined { + const match = + /^[ \t]*@exercise\b[^\n]*\n(?:[^\n]*\n)*?[ \t]*def[ \t]+(\w+)/m.exec( + source, + ); + return match?.[1]; +} + +/** + * Title and description for an exercise, taken from the markdown cell that + * introduces it: the nearest preceding markdown cell that isn't itself tagged. + * + * The cell's last heading becomes the title (dropping a leading "Exercise:", + * which reads naturally in the notebook but is redundant in the progress tree); + * the remaining prose becomes the description. + */ +function precedingPrompt( + cells: RawCell[], + exerciseIndex: number, + id: string, +): { title: string; description: string } { + for (let i = exerciseIndex - 1; i >= 0; i--) { + const cell = cells[i]; + const tags = cellTags(cell); + if ( + tags.includes(EXERCISE_TAG) || + AUTHORING_TAGS.some((t) => tags.includes(t)) + ) { + break; + } + if (cellKind(cell) !== "markdown") { + continue; + } + return splitPrompt(cellSource(cell), id); + } + return { title: id, description: "" }; +} + +function splitPrompt( + markdown: string, + id: string, +): { title: string; description: string } { + const lines = markdown.split(/\r?\n/); + let headingIndex = -1; + for (let i = 0; i < lines.length; i++) { + if (/^\s{0,3}#{1,6}\s+\S/.test(lines[i])) { + headingIndex = i; + } + } + if (headingIndex < 0) { + return { title: id, description: markdown.trim() }; + } + + const title = lines[headingIndex] + .replace(/^\s{0,3}#{1,6}\s+/, "") + .replace(/\s+#*\s*$/, "") + .replace(/^exercise\s*[:—-]\s*/i, "") + .trim(); + + return { + title: title || id, + description: lines + .slice(headingIndex + 1) + .join("\n") + .trim(), + }; +} diff --git a/source/vscode/src/learning/python/pythonRunner.ts b/source/vscode/src/learning/python/pythonRunner.ts index b1170d9a83d..3b761f5a1ae 100644 --- a/source/vscode/src/learning/python/pythonRunner.ts +++ b/source/vscode/src/learning/python/pythonRunner.ts @@ -4,6 +4,7 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { WORKBOOK_SUFFIX } from "../constants.js"; +import { stripAuthoringCells } from "../notebookExercises.js"; import type { CatalogCourse } from "../types.js"; // TODO (acasey): rename this @@ -92,8 +93,8 @@ export class PythonCourseRunner { } /** - * Materialize the working copy for every unit in the course: copy each - * authored notebook to its `*.workbook.ipynb` sibling. Existing workbooks + * Materialize the working copy for every unit in the course: derive each + * `*.workbook.ipynb` sibling from the authored notebook. Existing workbooks * are never overwritten, preserving learner edits. */ async materializeCourse(course: CatalogCourse): Promise { @@ -106,16 +107,24 @@ export class PythonCourseRunner { if (!unit.notebookRel) { continue; } - await this.copyIfMissing( + const dest = vscode.Uri.joinPath( + sourceRoot, + toWorkbookRel(unit.notebookRel), + ); + if (await uriExists(dest)) { + continue; + } + await this.materializeNotebook( vscode.Uri.joinPath(sourceRoot, unit.notebookRel), - vscode.Uri.joinPath(sourceRoot, toWorkbookRel(unit.notebookRel)), + dest, + unit.id, ); } } /** * Re-materialize a single unit: overwrite its `*.workbook.ipynb` - * with a fresh copy of the authored notebook. + * with a fresh copy derived from the authored notebook. */ async rematerializeUnit( course: CatalogCourse, @@ -130,28 +139,44 @@ export class PythonCourseRunner { } const sourceRoot = vscode.Uri.parse(course.sourceDir); - const src = vscode.Uri.joinPath(sourceRoot, unit.notebookRel); - const dest = vscode.Uri.joinPath( - sourceRoot, - toWorkbookRel(unit.notebookRel), + await this.materializeNotebook( + vscode.Uri.joinPath(sourceRoot, unit.notebookRel), + vscode.Uri.joinPath(sourceRoot, toWorkbookRel(unit.notebookRel)), + unit.id, ); - await ensureParentDir(dest); - await vscode.workspace.fs.copy(src, dest, { overwrite: true }); } - /** Copy a file only if the destination doesn't already exist. */ - private async copyIfMissing( + /** + * Write a unit's working copy: the authored notebook minus its author-only + * cells (hints, solutions, explanations). + * + * If the notebook can't be parsed we fall back to copying it verbatim, so a + * malformed notebook still leaves the learner with something to work in + * rather than nothing. + */ + private async materializeNotebook( src: vscode.Uri, dest: vscode.Uri, + unitId: string, ): Promise { - if (await uriExists(dest)) { - return; - } try { await ensureParentDir(dest); - await vscode.workspace.fs.copy(src, dest, { overwrite: false }); + const text = new TextDecoder().decode( + await vscode.workspace.fs.readFile(src), + ); + const stripped = stripAuthoringCells(text, unitId); + if (stripped === undefined) { + await vscode.workspace.fs.copy(src, dest, { overwrite: true }); + return; + } + await vscode.workspace.fs.writeFile( + dest, + new TextEncoder().encode(stripped), + ); } catch (e) { - log.warn(`Failed to copy ${src.fsPath} → ${dest.fsPath}: ${String(e)}`); + log.warn( + `Failed to materialize ${src.fsPath} → ${dest.fsPath}: ${String(e)}`, + ); } } } diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index 79be5c6c38f..09d993e86c0 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -265,16 +265,18 @@ export interface CatalogLesson { export type CatalogActivity = CatalogExercise | CatalogLesson; /** - * Exercise metadata loaded from a per-unit `exercises.json` sidecar - * (python-notebook courses). Provides hints, solutions, and descriptions - * for the chat LM tools without requiring cell parsing or execution. + * Exercise metadata for a `python-notebook` unit, parsed from cell tags in + * the authored notebook. Provides hints, solutions, and descriptions for the + * chat LM tools without requiring cell execution. */ export interface NotebookExerciseInfo { + /** Name of the `@exercise`-decorated function the learner implements. */ id: string; title: string; description: string; hints: string[]; - solution: string; + /** Reference solutions, one per `solution`-tagged cell. */ + solutions: string[]; solutionExplanation: string; /** Stable cell ID (from the notebook's cell metadata) for this exercise. */ cellId: string; @@ -285,8 +287,8 @@ export interface CatalogUnit { title: string; activities: CatalogActivity[]; /** - * Exercise metadata for python-notebook courses, loaded from - * `exercises.json`. Used by chat LM tools for hints/solutions. + * Exercise metadata for python-notebook courses, parsed from the authored + * notebook's cell tags. Used by chat LM tools for hints/solutions. */ notebookExercises?: NotebookExerciseInfo[]; /** diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/exercises.json deleted file mode 100644 index cbf93159cd5..00000000000 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/exercises.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "exercises": [ - { - "id": "forty_two", - "cellId": "db329ce6", - "title": "Your first Q# expression", - "description": "Implement the forty_two() function so it returns 42.", - "hints": [ - "The function just needs to return a value equal to the integer 42.", - "The simplest answer is to return the literal `42` itself." - ], - "solution": "@exercise\ndef forty_two():\n return qsharp.eval(\"40 + 2\")", - "solutionExplanation": "Any Q# expression that evaluates to the integer 42 works. The simplest options are the literal `42` or an arithmetic expression like `40 + 2`." - } - ] -} diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb index 57b8f7da15b..316c785fe34 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb @@ -91,7 +91,11 @@ "cell_type": "code", "execution_count": null, "id": "db329ce6", - "metadata": {}, + "metadata": { + "tags": [ + "exercise" + ] + }, "outputs": [], "source": [ "from _unit import exercise\n", @@ -105,6 +109,74 @@ " return qsharp.eval(\"0\") # <-- edit this expression" ] }, + { + "cell_type": "markdown", + "id": "5f1a20c4", + "metadata": { + "tags": [ + "hint" + ] + }, + "source": [ + "The function just needs to return a value equal to the integer 42." + ] + }, + { + "cell_type": "markdown", + "id": "7b0c93ad", + "metadata": { + "tags": [ + "hint" + ] + }, + "source": [ + "The simplest answer is to return the literal `42` itself." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c6e48f2", + "metadata": { + "tags": [ + "solution" + ] + }, + "outputs": [], + "source": [ + "@exercise\n", + "def forty_two():\n", + " return qsharp.eval(\"42\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d47b1e0", + "metadata": { + "tags": [ + "solution" + ] + }, + "outputs": [], + "source": [ + "@exercise\n", + "def forty_two():\n", + " return qsharp.eval(\"40 + 2\")" + ] + }, + { + "cell_type": "markdown", + "id": "e3a5c718", + "metadata": { + "tags": [ + "explanation" + ] + }, + "source": [ + "Any Q# expression that evaluates to the integer 42 works. The simplest options are the literal `42` or an arithmetic expression like `40 + 2`." + ] + }, { "cell_type": "markdown", "id": "d9a84106", diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb index 5353952f036..22944e4690b 100644 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb @@ -134,7 +134,11 @@ "cell_type": "code", "execution_count": null, "id": "12d649d7", - "metadata": {}, + "metadata": { + "tags": [ + "exercise" + ] + }, "outputs": [], "source": [ "from _unit import exercise\n", @@ -149,6 +153,58 @@ " return None # <-- replace this" ] }, + { + "cell_type": "markdown", + "id": "6a1f8c02", + "metadata": { + "tags": [ + "hint" + ] + }, + "source": [ + "The `operation=` parameter takes a string — the name of a Q# operation that accepts only qubits or qubit arrays." + ] + }, + { + "cell_type": "markdown", + "id": "b4e70d95", + "metadata": { + "tags": [ + "hint" + ] + }, + "source": [ + "Return `circuit(operation=\"PrepareCatState\")`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8c25a7f1", + "metadata": { + "tags": [ + "solution" + ] + }, + "outputs": [], + "source": [ + "@exercise\n", + "def cat_circuit():\n", + " return circuit(operation=\"PrepareCatState\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0d3b866", + "metadata": { + "tags": [ + "explanation" + ] + }, + "source": [ + "The `operation=` parameter lets the renderer decide the qubit allocation. Pass the operation name as a string without parentheses or arguments." + ] + }, { "cell_type": "markdown", "id": "c4d8a1e1", @@ -163,7 +219,11 @@ "cell_type": "code", "execution_count": null, "id": "c8d8aca1", - "metadata": {}, + "metadata": { + "tags": [ + "exercise" + ] + }, "outputs": [], "source": [ "from _unit import exercise\n", @@ -178,6 +238,58 @@ " return None # <-- replace this" ] }, + { + "cell_type": "markdown", + "id": "2e94af37", + "metadata": { + "tags": [ + "hint" + ] + }, + "source": [ + "Pass `group_by_scope=False` to `circuit()` to disable grouping." + ] + }, + { + "cell_type": "markdown", + "id": "aa7c1b58", + "metadata": { + "tags": [ + "hint" + ] + }, + "source": [ + "Return `circuit(\"GHZ(3)\", group_by_scope=False)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d51e6f24", + "metadata": { + "tags": [ + "solution" + ] + }, + "outputs": [], + "source": [ + "@exercise\n", + "def flat_circuit():\n", + " return circuit(\"GHZ(3)\", group_by_scope=False)" + ] + }, + { + "cell_type": "markdown", + "id": "9b3fd0ae", + "metadata": { + "tags": [ + "explanation" + ] + }, + "source": [ + "Setting `group_by_scope=False` tells the renderer to flatten all operations instead of grouping them by their containing scope (function calls, loops)." + ] + }, { "cell_type": "markdown", "id": "3d81eb01", diff --git a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/exercises.json b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/exercises.json deleted file mode 100644 index 303186a1826..00000000000 --- a/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/exercises.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "exercises": [ - { - "id": "cat_circuit", - "cellId": "12d649d7", - "title": "Render with operation=", - "description": "Implement cat_circuit() to return a circuit built with circuit() using the operation= parameter for PrepareCatState.", - "hints": [ - "The `operation=` parameter takes a string — the name of a Q# operation that accepts only qubits or qubit arrays.", - "Return `circuit(operation=\"PrepareCatState\")`." - ], - "solution": "@exercise\ndef cat_circuit():\n return circuit(operation=\"PrepareCatState\")", - "solutionExplanation": "The `operation=` parameter lets the renderer decide the qubit allocation. Pass the operation name as a string without parentheses or arguments." - }, - { - "id": "flat_circuit", - "cellId": "c8d8aca1", - "title": "Flatten a grouped circuit", - "description": "Implement flat_circuit() to return a circuit for GHZ(3) with grouping disabled so each gate is shown individually.", - "hints": [ - "Pass `group_by_scope=False` to `circuit()` to disable grouping.", - "Return `circuit(\"GHZ(3)\", group_by_scope=False)`." - ], - "solution": "@exercise\ndef flat_circuit():\n return circuit(\"GHZ(3)\", group_by_scope=False)", - "solutionExplanation": "Setting `group_by_scope=False` tells the renderer to flatten all operations instead of grouping them by their containing scope (function calls, loops)." - } - ] -} From b447b5b6e4a4f9b9688877aa418698623dd19238 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 27 Jul 2026 11:38:21 -0700 Subject: [PATCH 056/101] Update codespaces launch.json --- .vscode/launch.shared.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.shared.json b/.vscode/launch.shared.json index ef37f664b77..aa40683eee3 100644 --- a/.vscode/launch.shared.json +++ b/.vscode/launch.shared.json @@ -59,7 +59,10 @@ "--profile=dev", "--remote=codespaces+${env:CODESPACE_NAME}", "--extensionDevelopmentPath=${workspaceFolder}/source/vscode", - "${workspaceFolder}/samples/" + "${workspaceFolder}/source/vscode/test/suites/learning/test-workspace" + ], + "outFiles": [ + "${workspaceFolder}/source/vscode/out/**/*.js" ] } ] From 1178c5151e9256044155a39e8fe60b9ba6c5ff06 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 27 Jul 2026 15:05:44 -0700 Subject: [PATCH 057/101] TODO for new environment approach --- source/vscode/src/learning/python/environment.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 6d59cd51410..b65ba535867 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -187,6 +187,11 @@ export class EnvironmentManager { return cached; } + // TODO (acasey): try this. It will persist the location to the workspace metadata and there's a chance jupyter will find it there + // const project = api.addPythonProject({name: "Some Project", uri: someUri }); // Can drop result - just want side effect + // await api.refreshEnvironments(someUri); // As now + // const envs = await api.getEnvironments(someUri); // React somehow if there are multiple + // Without a refresh, getEnvironment seems to pick up the global install await api.refreshEnvironments(courseRoot); const env = await api.getEnvironment(courseRoot); From e3f0a518036acda0fac9ab852224e4ed2b64ff67 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 27 Jul 2026 16:44:12 -0700 Subject: [PATCH 058/101] Ignore vscode folders in test workspaces --- source/vscode/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/source/vscode/.gitignore b/source/vscode/.gitignore index 2e84c1bd959..b1d9bdf53b2 100644 --- a/source/vscode/.gitignore +++ b/source/vscode/.gitignore @@ -2,3 +2,4 @@ out/ test/out/ wasm/ *.vsix +.vscode/ From 2dd552df2ab2cde812e63855cd2e841e2afa86d4 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 27 Jul 2026 18:43:10 -0700 Subject: [PATCH 059/101] Create a python project for the active course --- source/vscode/src/learning/commands.ts | 2 + .../vscode/src/learning/python/environment.ts | 59 ++++++++++++------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 3c92feea8f5..4a4fb05f495 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -267,6 +267,8 @@ async function openCourseNotebook( const cellId = service.getCurrentExerciseCellId(); let opened = false; + // TODO (acasey): this may be unnecessary if we create a python project + // TODO (acasey): switch to a proposed/unstable API // Try to open via the Jupyter extension's unstable API so the course's // Python environment is automatically set as the active kernel. try { diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index b65ba535867..7c0968da26a 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -187,28 +187,47 @@ export class EnvironmentManager { return cached; } - // TODO (acasey): try this. It will persist the location to the workspace metadata and there's a chance jupyter will find it there - // const project = api.addPythonProject({name: "Some Project", uri: someUri }); // Can drop result - just want side effect - // await api.refreshEnvironments(someUri); // As now - // const envs = await api.getEnvironments(someUri); // React somehow if there are multiple - - // Without a refresh, getEnvironment seems to pick up the global install - await api.refreshEnvironments(courseRoot); - const env = await api.getEnvironment(courseRoot); - if (env) { - // If there's no local venv, getEnvironment will return the global install - const envPath = env.environmentPath.toString(); - const rootPath = courseRoot.toString().replace(/\/?$/, "/"); - if (!envPath.startsWith(rootPath)) { - log.debug( - `Ignoring environment "${env.name}" at ${envPath} ` + - `because it is not under ${rootPath}`, - ); + // TODO (acasey): pick an approach + // This version creates a workspace setting, which could be noise for the user + // but seems to cause Jupyter to pick up the venv and might make other + // python environment operations easier in the future + const courseName = courseRoot.path.split("/").pop(); + void api.addPythonProject({ + name: `QDK Course: ${courseName}`, + uri: courseRoot, + }); // Can drop result - just want side effect + await api.refreshEnvironments(courseRoot); // As now + const envs = await api.getEnvironments(courseRoot); // React somehow if there are multiple + + switch (envs.length) { + case 0: return undefined; - } - this._projectEnvironmentMap.set(courseRoot.toString(), env); + case 1: + return envs[0]; // TODO (acasey): need to enforce location? + default: + log.warn( + `Found multiple virtual environments, using first: ${envs.join(", ")}`, + ); + return envs[0]; } - return env; + + // // Without a refresh, getEnvironment seems to pick up the global install + // await api.refreshEnvironments(courseRoot); + // const env = await api.getEnvironment(courseRoot); + // if (env) { + // // If there's no local venv, getEnvironment will return the global install + // const envPath = env.environmentPath.toString(); + // const rootPath = courseRoot.toString().replace(/\/?$/, "/"); + // if (!envPath.startsWith(rootPath)) { + // log.debug( + // `Ignoring environment "${env.name}" at ${envPath} ` + + // `because it is not under ${rootPath}`, + // ); + // return undefined; + // } + // this._projectEnvironmentMap.set(courseRoot.toString(), env); + // } + // return env; } } From 4fc6f2773ab5b3af8c7884fcc9d718a0516470c0 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 27 Jul 2026 19:59:31 -0700 Subject: [PATCH 060/101] Initialize service when VS Code session starts with open notebook --- source/vscode/src/learning/index.ts | 42 +----- .../src/learning/notebookCellStatusBar.ts | 128 ++++++++++-------- source/vscode/src/learning/notebookSync.ts | 104 ++++++++++++++ source/vscode/src/learning/service.ts | 98 ++++++++++++++ 4 files changed, 282 insertions(+), 90 deletions(-) create mode 100644 source/vscode/src/learning/notebookSync.ts diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index 9cc3a87557e..70c4d3bcca5 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -7,12 +7,9 @@ import { exerciseDocumentSelector, } from "./codeLens.js"; import { registerLearningCommands } from "./commands.js"; -import { - LEARNING_NOTEBOOK_ACTIVE_CONTEXT, - WORKBOOK_SUFFIX, -} from "./constants.js"; import { LessonPanelManager, registerLessonPanelSerializer } from "./panel.js"; import { createNotebookCellStatusBarProvider } from "./notebookCellStatusBar.js"; +import { registerNotebookSync } from "./notebookSync.js"; import { registerLearningProgressView } from "./progressTreeView.js"; import { LearningService } from "./service.js"; import { registerLearningWelcomeView } from "./welcomeView.js"; @@ -35,10 +32,13 @@ export function initLearning( createLearningCodeLensProvider(), ), ); + const cellStatusBarProvider = + createNotebookCellStatusBarProvider(learningService); context.subscriptions.push( + cellStatusBarProvider, vscode.notebooks.registerNotebookCellStatusBarItemProvider( "jupyter-notebook", - createNotebookCellStatusBarProvider(learningService), + cellStatusBarProvider, ), ); context.subscriptions.push( @@ -81,40 +81,10 @@ export function initLearning( registerLearningWelcomeView(context, learningService); registerLearningCommands(context, learningService, panelManager); registerLessonPanelSerializer(context, panelManager); - registerNotebookContextKey(context, learningService); + registerNotebookSync(context, learningService); return learningService; } -/** - * Keep {@link LEARNING_NOTEBOOK_ACTIVE_CONTEXT} in sync with the active - * notebook editor so notebook toolbar actions only appear on course - * workbooks, not on every Jupyter notebook the user has open. - */ -function registerNotebookContextKey( - context: vscode.ExtensionContext, - service: LearningService, -): void { - const sync = (editor: vscode.NotebookEditor | undefined) => { - let isCourseNotebook = false; - if (editor && service.initialized) { - const uri = editor.notebook.uri.toString(); - isCourseNotebook = - uri.startsWith(service.learningContentRoot.toString()) && - uri.endsWith(WORKBOOK_SUFFIX); - } - void vscode.commands.executeCommand( - "setContext", - LEARNING_NOTEBOOK_ACTIVE_CONTEXT, - isCourseNotebook, - ); - }; - - context.subscriptions.push( - vscode.window.onDidChangeActiveNotebookEditor(sync), - ); - sync(vscode.window.activeNotebookEditor); -} - export type { CourseDescriptor, CourseKind, diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts index 24673427172..a1b8f00cf53 100644 --- a/source/vscode/src/learning/notebookCellStatusBar.ts +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -11,68 +11,88 @@ import type { LearningService } from "./service.js"; */ export function createNotebookCellStatusBarProvider( service: LearningService, -): vscode.NotebookCellStatusBarItemProvider { +): LearningCellStatusBarProvider { // TODO (acasey): clean up logging log.debug("createNotebookCellStatusBarProvider"); - return { - provideCellStatusBarItems( - cell: vscode.NotebookCell, - ): vscode.NotebookCellStatusBarItem[] { - log.debug("provideCellStatusBarItems called for cell %d", cell.index); + return new LearningCellStatusBarProvider(service); +} - if (!service.initialized) { - log.debug("Skipping status bar: service not initialized"); - return []; - } +class LearningCellStatusBarProvider + implements vscode.NotebookCellStatusBarItemProvider, vscode.Disposable +{ + private readonly _onDidChangeCellStatusBarItems = + new vscode.EventEmitter(); + readonly onDidChangeCellStatusBarItems = + this._onDidChangeCellStatusBarItems.event; - const courseInfo = service.getActiveCourseInfo(); - if (courseInfo.kind !== "python-notebook") { - log.debug( - "Skipping status bar: course kind is '%s', not 'python-notebook'", - courseInfo.kind, - ); - return []; - } + private readonly subscription: vscode.Disposable; - // Only annotate code cells that are exercises. - if (cell.kind !== vscode.NotebookCellKind.Code) { - log.debug( - "Skipping status bar: cell %d is not a code cell", - cell.index, - ); - return []; - } + constructor(private readonly service: LearningService) { + // VS Code caches the items it gets from a provider. A workbook is + // usually opened before the service has any state to answer with, so + // without this the buttons would never appear. + this.subscription = service.onDidChangeState(() => + this._onDidChangeCellStatusBarItems.fire(), + ); + } - // Use the cell's stable ID from notebook metadata. - const cellId = cell.metadata?.id; - if (typeof cellId !== "string") { - log.debug( - "Skipping status bar: cell %d has no metadata.id", - cell.index, - ); - return []; - } + dispose(): void { + this.subscription.dispose(); + this._onDidChangeCellStatusBarItems.dispose(); + } - // Only show the hint button for cells that are exercises. - const exerciseCellIds = service.getExerciseCellIds(); - if (!exerciseCellIds.has(cellId)) { - log.debug("Skipping status bar: cell %s is not an exercise", cellId); - return []; - } + provideCellStatusBarItems( + cell: vscode.NotebookCell, + ): vscode.NotebookCellStatusBarItem[] { + const service = this.service; + log.debug("provideCellStatusBarItems called for cell %d", cell.index); - log.debug("Adding 'Ask for a Hint' status bar item for cell %s", cellId); + if (!service.initialized) { + log.debug("Skipping status bar: service not initialized"); + return []; + } - const item = new vscode.NotebookCellStatusBarItem( - "$(comment-discussion-sparkle) Ask for a Hint", - vscode.NotebookCellStatusBarAlignment.Right, + const courseInfo = service.getActiveCourseInfo(); + if (courseInfo.kind !== "python-notebook") { + log.debug( + "Skipping status bar: course kind is '%s', not 'python-notebook'", + courseInfo.kind, ); - item.command = { - title: "Ask for a Hint", - command: "qsharp-vscode.learningNotebookHint", - arguments: [cellId], - }; - item.tooltip = "Open Copilot Chat for a hint on this exercise"; - return [item]; - }, - }; + return []; + } + + // Only annotate code cells that are exercises. + if (cell.kind !== vscode.NotebookCellKind.Code) { + log.debug("Skipping status bar: cell %d is not a code cell", cell.index); + return []; + } + + // Use the cell's stable ID from notebook metadata. + const cellId = cell.metadata?.id; + if (typeof cellId !== "string") { + log.debug("Skipping status bar: cell %d has no metadata.id", cell.index); + return []; + } + + // Only show the hint button for cells that are exercises. + const exerciseCellIds = service.getExerciseCellIds(); + if (!exerciseCellIds.has(cellId)) { + log.debug("Skipping status bar: cell %s is not an exercise", cellId); + return []; + } + + log.debug("Adding 'Ask for a Hint' status bar item for cell %s", cellId); + + const item = new vscode.NotebookCellStatusBarItem( + "$(comment-discussion-sparkle) Ask for a Hint", + vscode.NotebookCellStatusBarAlignment.Right, + ); + item.command = { + title: "Ask for a Hint", + command: "qsharp-vscode.learningNotebookHint", + arguments: [cellId], + }; + item.tooltip = "Open Copilot Chat for a hint on this exercise"; + return [item]; + } } diff --git a/source/vscode/src/learning/notebookSync.ts b/source/vscode/src/learning/notebookSync.ts new file mode 100644 index 00000000000..6f4cbf6b1ce --- /dev/null +++ b/source/vscode/src/learning/notebookSync.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { + LEARNING_COURSES_SUBDIR, + LEARNING_NOTEBOOK_ACTIVE_CONTEXT, + LEARNING_WORKSPACE_FOLDER, + WORKBOOK_SUFFIX, +} from "./constants.js"; +import type { LearningService } from "./service.js"; + +/** + * Keep the learning service and {@link LEARNING_NOTEBOOK_ACTIVE_CONTEXT} in + * sync with the active notebook editor. + * + * VS Code activates this extension for any Jupyter notebook, so a session + * can be restored with a course workbook in the editor and the learning + * views never shown. Nothing else would initialize the service in that + * case, leaving the notebook without its toolbar actions, hint buttons, or + * exercise completion tracking. + */ +export function registerNotebookSync( + context: vscode.ExtensionContext, + service: LearningService, +): void { + const sync = (editor: vscode.NotebookEditor | undefined) => + void syncActiveNotebook(service, editor); + + context.subscriptions.push( + vscode.window.onDidChangeActiveNotebookEditor(sync), + ); + sync(vscode.window.activeNotebookEditor); +} + +async function syncActiveNotebook( + service: LearningService, + editor: vscode.NotebookEditor | undefined, +): Promise { + if (editor && isCandidateWorkbookUri(editor.notebook.uri)) { + // Detect-only — never `createIfMissing`. A `*.workbook.ipynb` is + // generated during initialization, so its presence normally implies a + // learning workspace already exists. When it doesn't, the learner + // hasn't started yet and merely opening a notebook must not scaffold + // one behind their back. + if (await service.tryInitialize()) { + await service.syncToWorkbook(editor.notebook.uri); + } + } + + // The awaits above can outlive the editor that triggered them. + if (vscode.window.activeNotebookEditor !== editor) { + return; + } + + void vscode.commands.executeCommand( + "setContext", + LEARNING_NOTEBOOK_ACTIVE_CONTEXT, + editor !== undefined && isCourseWorkbook(service, editor.notebook.uri), + ); +} + +/** + * True when a URI *looks like* a course workbook, judged purely from its + * path: `/qdk-learning/courses/**\/*.workbook.ipynb`. + * + * Does no I/O and doesn't consult the service, so it is safe to call before + * the learning workspace has been loaded. Drop-in courses are only ever + * discovered under that folder pair, and only python-notebook courses + * produce `*.workbook.ipynb` files, so a match can never be a Q# artifact. + */ +function isCandidateWorkbookUri(uri: vscode.Uri): boolean { + if (!uri.path.endsWith(WORKBOOK_SUFFIX)) { + return false; + } + const target = uri.toString(); + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const coursesRoot = vscode.Uri.joinPath( + folder.uri, + LEARNING_WORKSPACE_FOLDER, + LEARNING_COURSES_SUBDIR, + ).toString(); + if (target.startsWith(`${coursesRoot}/`)) { + return true; + } + } + return false; +} + +/** + * True when the URI is a course workbook belonging to the loaded learning + * workspace. Scopes notebook toolbar actions to learning content rather + * than every Jupyter notebook the user has open. + */ +function isCourseWorkbook(service: LearningService, uri: vscode.Uri): boolean { + if (!service.initialized) { + return false; + } + const target = uri.toString(); + return ( + target.startsWith(service.learningContentRoot.toString()) && + target.endsWith(WORKBOOK_SUFFIX) + ); +} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 57321f2ae52..16e720665c1 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -439,6 +439,99 @@ export class LearningService { return true; } + /** + * Move the current position to the unit backing the given workbook URI, + * so that unit-scoped UI (hint status bar items, notebook toolbar actions, + * completion tracking) applies to the notebook the learner is looking at. + * + * Returns `true` when the URI belongs to a known course workbook, whether + * or not the position actually had to move. + */ + async syncToWorkbook(uri: vscode.Uri): Promise { + if (!this.workspace) { + return false; + } + const resolved = this.resolveWorkbookLocation(uri); + if (!resolved) { + return false; + } + const { course, unit } = resolved; + + // Compare on the unit and never the activity: commands navigate to a + // specific activity and *then* open its notebook, so re-deriving the + // activity here would undo that. The guard is also what terminates the + // open-notebook -> active-editor-change -> sync feedback loop. + const pos = this.position; + if (pos.courseId === course.id && pos.unitId === unit.id) { + return true; + } + + this.workspace.progressData.position = this.firstIncompleteInUnit( + course, + unit, + ); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + return true; + } + + /** + * Resolve a `*.workbook.ipynb` URI to the course and unit that own it. + * Only python-notebook courses have workbooks. + * + * Purely in-memory: every course is already loaded by `loadWorkspace`, + * so this is a handful of string comparisons and cheap enough to run on + * every active-editor change. + */ + private resolveWorkbookLocation( + uri: vscode.Uri, + ): { course: CatalogCourse; unit: CatalogUnit } | undefined { + const target = uri.toString(); + for (const course of this.requireWorkspace().courses.values()) { + if (course.kind !== "python-notebook" || !course.sourceDir) { + continue; + } + for (const unit of course.units) { + if (!unit.notebookRel) { + continue; + } + const workbook = this.pythonRunner.workbookFileUri( + course, + unit.notebookRel, + ); + if (workbook.toString() === target) { + return { course, unit }; + } + } + } + return undefined; + } + + /** + * The first activity in a unit that has not been completed, or the unit's + * first activity when everything in it is already done. + */ + private firstIncompleteInUnit( + course: CatalogCourse, + unit: CatalogUnit, + ): ActivityLocation { + for (const activity of unit.activities) { + const location: ActivityLocation = { + courseId: course.id, + unitId: unit.id, + activityId: activity.id, + }; + if (!this.isComplete(location)) { + return location; + } + } + return { + courseId: course.id, + unitId: unit.id, + activityId: unit.activities[0]?.id ?? "", + }; + } + /** * Returns the set of cell IDs that correspond to exercises in the * current unit. Empty if the course isn't a python-notebook course or @@ -1286,6 +1379,10 @@ export class LearningService { { isFirstTime: "false" }, {}, ); + // Surfaces registered before initialization (notebook cell status bar + // items, the lesson panel) need a nudge to re-query now that there is + // state to read. + this._onDidChangeState.fire(this.getState()); return true; } @@ -1316,6 +1413,7 @@ export class LearningService { { isFirstTime: "true" }, {}, ); + this._onDidChangeState.fire(this.getState()); return true; } From cc493f2c36e711c5e1f53abb985f9215417e9fbd Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 27 Jul 2026 22:53:54 -0700 Subject: [PATCH 061/101] Switch the cell context menu button from hint to explain --- source/vscode/package.json | 14 ++++++- source/vscode/src/learning/commands.ts | 58 ++++++++++++++++++++------ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/source/vscode/package.json b/source/vscode/package.json index 8ada835a49c..27ee7290846 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -361,6 +361,10 @@ { "command": "qsharp-vscode.learningNotebookHint", "when": "false" + }, + { + "command": "qsharp-vscode.learningNotebookExplain", + "when": "false" } ], "view/title": [ @@ -480,8 +484,8 @@ ], "notebook/cell/title": [ { - "command": "qsharp-vscode.learningNotebookHint", - "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected && notebookCellType == 'code'", + "command": "qsharp-vscode.learningNotebookExplain", + "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", "group": "inline/cell@50" } ], @@ -794,6 +798,12 @@ "title": "Ask for a Hint", "category": "QDK Learning", "icon": "$(comment-discussion-sparkle)" + }, + { + "command": "qsharp-vscode.learningNotebookExplain", + "title": "Explain", + "category": "QDK Learning", + "icon": "$(comment-discussion-sparkle)" } ], "breakpoints": [ diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 4a4fb05f495..e541f6715c6 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -222,18 +222,7 @@ export function registerLearningCommands( return; } - // Resolve cell ID from the argument: - // - string: passed directly from the cell status bar item - // - { cell }: passed by VS Code when invoked from notebook/cell/title - let cellId: string | undefined; - if (typeof arg === "string") { - cellId = arg; - } else if (arg && "cell" in arg) { - const id = arg.cell.metadata?.id; - if (typeof id === "string") { - cellId = id; - } - } + const cellId = resolveCellId(arg); // Navigate to the exercise so the service state matches. if (cellId) { @@ -245,9 +234,54 @@ export function registerLearningCommands( }); }, ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningNotebookExplain", + async (arg?: string | { cell: vscode.NotebookCell }) => { + if (!service.initialized) { + return; + } + + const courseInfo = service.getActiveCourseInfo(); + if (courseInfo.kind !== "python-notebook") { + return; + } + + // The button is offered on every cell, so the cell may not be an + // exercise. Only move the service's position when it is one. + const cellId = resolveCellId(arg); + if (cellId && service.getExerciseCellIds().has(cellId)) { + await service.goToExerciseByCellId(cellId, "notebook"); + } + + await vscode.commands.executeCommand("workbench.action.chat.open", { + query: `/qdk-learning Explain this concept in more detail`, + }); + }, + ), ); } +/** + * Resolve a notebook cell ID from a command argument: + * - string: passed directly from the cell status bar item + * - { cell }: passed by VS Code when invoked from notebook/cell/title + */ +function resolveCellId( + arg?: string | { cell: vscode.NotebookCell }, +): string | undefined { + if (typeof arg === "string") { + return arg; + } + if (arg && "cell" in arg) { + const id = arg.cell.metadata?.id; + if (typeof id === "string") { + return id; + } + } + return undefined; +} + /** * Open the current unit's notebook working copy, pre-selecting the course's * Python environment as the active kernel. From 4253e93a4fb7474ffc3898529822418fbdba837a Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 09:37:31 -0700 Subject: [PATCH 062/101] Add TODOs --- source/vscode/src/learning/commands.ts | 1 + source/vscode/src/learning/python/environment.ts | 2 +- source/vscode/src/learning/service.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index e541f6715c6..036db8ba9b3 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -589,6 +589,7 @@ async function runEnvironmentCheckCommand( } await service.applyEnvironmentCheckFix(fix); + // TODO (acasey): this may no longer be necessary if jupyter picks up python projects // These fixes change how the notebook binds to a kernel (creating the // course environment, or installing the Python/Jupyter extensions), so // close and re-open the notebook to pick up the new environment. diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 7c0968da26a..8c91fe6e5b8 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -193,7 +193,7 @@ export class EnvironmentManager { // python environment operations easier in the future const courseName = courseRoot.path.split("/").pop(); void api.addPythonProject({ - name: `QDK Course: ${courseName}`, + name: `QDK Course: ${courseName}`, // This doesn't seem to persist across sessions uri: courseRoot, }); // Can drop result - just want side effect await api.refreshEnvironments(courseRoot); // As now diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 16e720665c1..54790e6aa9a 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1957,6 +1957,7 @@ export class LearningService { this._onDidChangeProgress.fire(this._lastSnapshot); } + // TODO (acasey): de-dup against commands.ts /** * Close any open editor tabs whose URI matches the given notebook URI. */ From 958fab3fc0e8448955538abb8f729bfb70a227eb Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 09:41:31 -0700 Subject: [PATCH 063/101] Create the python project when the venv is created --- .../vscode/src/learning/python/environment.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 8c91fe6e5b8..b9d0deaa955 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -81,6 +81,14 @@ export class EnvironmentManager { // Cache the resolved environment. this._projectEnvironmentMap.set(courseRoot.toString(), env); + + // Register the course folder as a Python project. This creates a workspace + // setting, which causes Jupyter to pick up the venv. + const courseName = courseRoot.path.split("/").pop(); + await api.addPythonProject({ + name: `QDK Course: ${courseName}`, + uri: courseRoot, + }); } } @@ -187,15 +195,6 @@ export class EnvironmentManager { return cached; } - // TODO (acasey): pick an approach - // This version creates a workspace setting, which could be noise for the user - // but seems to cause Jupyter to pick up the venv and might make other - // python environment operations easier in the future - const courseName = courseRoot.path.split("/").pop(); - void api.addPythonProject({ - name: `QDK Course: ${courseName}`, // This doesn't seem to persist across sessions - uri: courseRoot, - }); // Can drop result - just want side effect await api.refreshEnvironments(courseRoot); // As now const envs = await api.getEnvironments(courseRoot); // React somehow if there are multiple From c4620f1962da2cbe62e37e0c161ba697ec6d762a Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 09:42:32 -0700 Subject: [PATCH 064/101] Clean up findEnvironment --- .../vscode/src/learning/python/environment.ts | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index b9d0deaa955..5966df8f08d 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -195,8 +195,8 @@ export class EnvironmentManager { return cached; } - await api.refreshEnvironments(courseRoot); // As now - const envs = await api.getEnvironments(courseRoot); // React somehow if there are multiple + await api.refreshEnvironments(courseRoot); + const envs = await api.getEnvironments(courseRoot); switch (envs.length) { case 0: @@ -209,24 +209,6 @@ export class EnvironmentManager { ); return envs[0]; } - - // // Without a refresh, getEnvironment seems to pick up the global install - // await api.refreshEnvironments(courseRoot); - // const env = await api.getEnvironment(courseRoot); - // if (env) { - // // If there's no local venv, getEnvironment will return the global install - // const envPath = env.environmentPath.toString(); - // const rootPath = courseRoot.toString().replace(/\/?$/, "/"); - // if (!envPath.startsWith(rootPath)) { - // log.debug( - // `Ignoring environment "${env.name}" at ${envPath} ` + - // `because it is not under ${rootPath}`, - // ); - // return undefined; - // } - // this._projectEnvironmentMap.set(courseRoot.toString(), env); - // } - // return env; } } From dffd88676dd207df2095d5652fd93789088a21c8 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 09:58:02 -0700 Subject: [PATCH 065/101] Don't reopen notebook after environment repair --- source/vscode/src/learning/commands.ts | 44 -------------------------- 1 file changed, 44 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 036db8ba9b3..8dca3e5b5e8 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -588,48 +588,4 @@ async function runEnvironmentCheckCommand( return; } await service.applyEnvironmentCheckFix(fix); - - // TODO (acasey): this may no longer be necessary if jupyter picks up python projects - // These fixes change how the notebook binds to a kernel (creating the - // course environment, or installing the Python/Jupyter extensions), so - // close and re-open the notebook to pick up the new environment. - // Best-effort: a failure here shouldn't make the fix look like it failed. - if (fix.kind === "setup" || fix.kind === "install-extensions") { - try { - const notebookUri = service.getCurrentCodeFileUri(); - if (notebookUri) { - await closeNotebook(notebookUri); - } - await openCourseNotebook(service); - } catch (e) { - log.warn(`Failed to re-open the course notebook after a fix: ${e}`); - } - } -} - -/** - * Close every tab showing the given notebook, saving first if it has unsaved - * changes so no confirmation dialog blocks the close. No-op when the notebook - * isn't open. - */ -async function closeNotebook(notebookUri: vscode.Uri): Promise { - const uriStr = notebookUri.toString(); - - const doc = vscode.workspace.notebookDocuments.find( - (n) => n.uri.toString() === uriStr, - ); - if (doc?.isDirty) { - await doc.save(); - } - - const tabs = vscode.window.tabGroups.all - .flatMap((group) => group.tabs) - .filter( - (tab) => - tab.input instanceof vscode.TabInputNotebook && - tab.input.uri.toString() === uriStr, - ); - if (tabs.length > 0) { - await vscode.window.tabGroups.close(tabs); - } } From 635d38d1a59fed0843d8e97a5b4beb393fc21cca Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 10:35:51 -0700 Subject: [PATCH 066/101] Drop test hook for now --- source/vscode/src/extension.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index b05ba7be2c0..2e222bd3955 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -17,7 +17,6 @@ import { startOtherQSharpDiagnostics } from "./diagnostics.js"; import { removeDeprecatedCopilotInstructions } from "./gh-copilot/instructions.js"; import { registerLanguageModelTools } from "./gh-copilot/tools.js"; import { initLearning } from "./learning/index.js"; -import type { LearningService } from "./learning/index.js"; import { activateLanguageService } from "./language-service/activate.js"; import { Logging, @@ -105,12 +104,6 @@ export async function activate( if (vscode.env.uiKind !== vscode.UIKind.Web) { const learningService = initLearning(context); registerLanguageModelTools(context, learningService); - if (context.extensionMode === vscode.ExtensionMode.Test) { - // Test-only seam: expose the learning service so integration tests can - // drive multi-course flows without UI automation. - // TODO (acasey): seems kind of suspicious that this would be the only test suite that needs this - api.learning = learningService; - } } // fire-and-forget removeDeprecatedCopilotInstructions(context); @@ -221,8 +214,6 @@ export interface ExtensionApi { // Only available in test mode. Allows listening to extension log events. logging?: Logging; setGithubEndpoint: (endpoint: string) => void; - // Only available in test mode on desktop. The multi-course learning service. - learning?: LearningService; } export class QsTextDocumentContentProvider From 508e33d8fb558e5657e1c9b102275d0783bd9893 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 10:39:37 -0700 Subject: [PATCH 067/101] INTERESTING Drop special openNotebook call --- source/vscode/src/learning/commands.ts | 45 ++++---------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 8dca3e5b5e8..d93790c1dd8 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -299,44 +299,14 @@ async function openCourseNotebook( return; } const cellId = service.getCurrentExerciseCellId(); - let opened = false; - - // TODO (acasey): this may be unnecessary if we create a python project - // TODO (acasey): switch to a proposed/unstable API - // Try to open via the Jupyter extension's unstable API so the course's - // Python environment is automatically set as the active kernel. - try { - const jupyter = vscode.extensions.getExtension("ms-toolsai.jupyter"); - const api = await jupyter?.activate(); - if (api && typeof api.openNotebook === "function") { - const envPath = await service.getJupyterEnvironmentPath(); - if (envPath) { - await api.openNotebook(notebookUri, envPath); - opened = true; - } else { - log.info("Didn't find a course virtual environment to use in notebook"); - } - } - if (!opened) { - log.warn( - "Jupyter openNotebook API is not available; falling back to generic open.", - ); - } - } catch (e) { - log.warn( - `Jupyter openNotebook API call failed: ${e}; falling back to generic open.`, - ); - } - if (!opened) { - // Fallback: open without pre-selecting a kernel. - await vscode.commands.executeCommand( - "vscode.openWith", - notebookUri, - "jupyter-notebook", - { viewColumn: vscode.ViewColumn.Active, preview: false }, - ); - } + // Fallback: open without pre-selecting a kernel. + await vscode.commands.executeCommand( + "vscode.openWith", + notebookUri, + "jupyter-notebook", + { viewColumn: vscode.ViewColumn.Active, preview: false }, + ); if (options?.reveal === "top") { revealNotebookTop(notebookUri); @@ -462,7 +432,6 @@ function nodeToLocation( * Resolve a target course id from a tree node, or prompt the user with a * quick pick when invoked without one (e.g. from the command palette). */ -// TODO (acasey): is this actually in the command palette? If not, do we need a picker? async function resolveCourseId( service: LearningService, node?: LearningProgressNode, From 6186a26d500cf9e5016f91d7ae3271cc04e3b4f3 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 10:43:47 -0700 Subject: [PATCH 068/101] Drop old logging --- .../vscode/src/learning/notebookCellStatusBar.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts index a1b8f00cf53..aa0c97bd644 100644 --- a/source/vscode/src/learning/notebookCellStatusBar.ts +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { log } from "qsharp-lang"; import * as vscode from "vscode"; import type { LearningService } from "./service.js"; @@ -12,8 +11,6 @@ import type { LearningService } from "./service.js"; export function createNotebookCellStatusBarProvider( service: LearningService, ): LearningCellStatusBarProvider { - // TODO (acasey): clean up logging - log.debug("createNotebookCellStatusBarProvider"); return new LearningCellStatusBarProvider(service); } @@ -45,44 +42,33 @@ class LearningCellStatusBarProvider cell: vscode.NotebookCell, ): vscode.NotebookCellStatusBarItem[] { const service = this.service; - log.debug("provideCellStatusBarItems called for cell %d", cell.index); if (!service.initialized) { - log.debug("Skipping status bar: service not initialized"); return []; } const courseInfo = service.getActiveCourseInfo(); if (courseInfo.kind !== "python-notebook") { - log.debug( - "Skipping status bar: course kind is '%s', not 'python-notebook'", - courseInfo.kind, - ); return []; } // Only annotate code cells that are exercises. if (cell.kind !== vscode.NotebookCellKind.Code) { - log.debug("Skipping status bar: cell %d is not a code cell", cell.index); return []; } // Use the cell's stable ID from notebook metadata. const cellId = cell.metadata?.id; if (typeof cellId !== "string") { - log.debug("Skipping status bar: cell %d has no metadata.id", cell.index); return []; } // Only show the hint button for cells that are exercises. const exerciseCellIds = service.getExerciseCellIds(); if (!exerciseCellIds.has(cellId)) { - log.debug("Skipping status bar: cell %s is not an exercise", cellId); return []; } - log.debug("Adding 'Ask for a Hint' status bar item for cell %s", cellId); - const item = new vscode.NotebookCellStatusBarItem( "$(comment-discussion-sparkle) Ask for a Hint", vscode.NotebookCellStatusBarAlignment.Right, From 7ac9d0b1337b8443f3bd4c7f0a45eb3dac17c8f9 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 10:46:14 -0700 Subject: [PATCH 069/101] Drop unused fix kinds --- source/vscode/src/learning/service.ts | 6 ------ source/vscode/src/learning/types.d.ts | 4 +--- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 54790e6aa9a..57d6b12fe8f 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -652,12 +652,6 @@ export class LearningService { case "install-extensions": await this.pythonRunner.promptInstallExtensions(); return; - case "select-kernel": - // TODO (acasey): is this ever offered? - await vscode.commands.executeCommand("notebook.selectKernel"); - return; - case "docs": - return; } } diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index 09d993e86c0..88618748a1d 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -367,10 +367,8 @@ export interface EnvironmentCheckFix { * What the fix does when chosen: * - `setup`: run the per-course environment setup. * - `install-extensions`: prompt to install Python/Jupyter. - * - `select-kernel`: re-select the course kernel for the notebook. - * - `docs`: informational only; no action. */ - kind: "setup" | "install-extensions" | "select-kernel" | "docs"; // TODO (acasey): select-kernel appears to be unused + kind: "setup" | "install-extensions"; } /** One diagnostic in an {@link EnvironmentCheckReport}. */ From 35447eb406474dc541e2cf39d0c5d70b0fd2d583 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 10:47:32 -0700 Subject: [PATCH 070/101] Clean up old TODOs --- source/vscode/src/gh-copilot/learningTools.ts | 1 - source/vscode/src/learning/courseProvider.ts | 4 ---- source/vscode/src/learning/python/environment.ts | 2 +- source/vscode/src/learning/service.ts | 2 -- source/vscode/src/learning/webview/webview-client.tsx | 2 +- 5 files changed, 2 insertions(+), 9 deletions(-) diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 2c89a197e18..73c0970e272 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -174,7 +174,6 @@ export class LearningTools { descriptor: CourseDescriptor | undefined; readme?: string; }> { - // TODO (acasey): drop readme? await this.ensureInitialized(); return this.invoke(async () => { const courseId = input?.courseId ?? this.service.getActiveCourseId(); diff --git a/source/vscode/src/learning/courseProvider.ts b/source/vscode/src/learning/courseProvider.ts index cbb4be4ae0f..eaffee4833d 100644 --- a/source/vscode/src/learning/courseProvider.ts +++ b/source/vscode/src/learning/courseProvider.ts @@ -7,10 +7,6 @@ import { loadKatasCourse } from "./catalog.js"; import { KATAS_COURSE_ID } from "./constants.js"; import type { CatalogCourse, CourseDescriptor } from "./types.js"; -// TODO (acasey): there are a bunch of places where we disable things in notebook courses - -// it seems like we should have a property on the interface instead of using the course kind string -// e.g. `this.activeCourse.kind === "python-notebook"` - /** * A source of learning courses. Implementations know how to enumerate the * courses they provide and how to fully load a course by id. diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 5966df8f08d..f0372b314a5 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -202,7 +202,7 @@ export class EnvironmentManager { case 0: return undefined; case 1: - return envs[0]; // TODO (acasey): need to enforce location? + return envs[0]; default: log.warn( `Found multiple virtual environments, using first: ${envs.join(", ")}`, diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 57d6b12fe8f..00fdf5f60b2 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -618,7 +618,6 @@ export class LearningService { /** Set up the environment for the currently-active course. */ async setupActiveEnvironment(): Promise { - // TODO (acasey): also set kernel, if possible await this.ensureEnvironment(this.activeCourse, { force: true }); } @@ -1951,7 +1950,6 @@ export class LearningService { this._onDidChangeProgress.fire(this._lastSnapshot); } - // TODO (acasey): de-dup against commands.ts /** * Close any open editor tabs whose URI matches the given notebook URI. */ diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 0a134e87225..4feea84b8ee 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -78,7 +78,7 @@ function reducer(state: AppState, action: AppAction): AppState { action.direction === "next" ? { type: "text", - text: "🎉 You have completed all content!", // TODO (acasey): clear this on reset + text: "🎉 You have completed all content!", variant: "pass", } : { type: "text", text: "Already at the beginning." }; From ae768f37a42a7b8dbb50b3f3a3be9c19c4edc292 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 10:50:03 -0700 Subject: [PATCH 071/101] Add clarifying comments --- source/vscode/src/learning/commands.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index d93790c1dd8..dbfe81d6d69 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -146,7 +146,7 @@ export function registerLearningCommands( async (node?: LearningProgressNode) => { const courseId = await resolveCourseId(service, node); if (!courseId) { - // TODO (acasey): at least log this + // This may simply indicate that the user declined to pick a course return; } await service.switchCourse(courseId, "tree"); @@ -174,7 +174,7 @@ export function registerLearningCommands( async (node?: LearningProgressNode) => { const courseId = await resolveCourseId(service, node); if (!courseId) { - // TODO (acasey): at least log this + // This may simply indicate that the user declined to pick a course return; } await showCourseInfo(service, courseId); From 2044b0b08aaebe76e910771eb06f9882e13d658d Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 10:57:42 -0700 Subject: [PATCH 072/101] Log finding the wrong number of notebooks in a unit --- .../src/learning/dropInCourseProvider.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 207e671652c..cb14cb998eb 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -239,19 +239,30 @@ export class DropInCourseProvider implements CourseProvider { // copies (`*.workbook.ipynb`) sit beside the source and must be ignored // here so they are never mistaken for the authored source notebook. const entries = await readDirSafe(unitDir); - const notebookEntry = entries + const notebookEntries = entries .filter( (e) => e.type === vscode.FileType.File && e.name.toLowerCase().endsWith(".ipynb") && !e.name.toLowerCase().endsWith(WORKBOOK_SUFFIX), ) - .sort((a, b) => a.name.localeCompare(b.name))[0]; // TODO (acasey): log finding multiple - if (!notebookEntry) { - log.warn( - `Unit "${unit.id}" has no .ipynb notebook in ${unitDir.fsPath}.`, - ); - return { activities: [] }; + .sort((a, b) => a.name.localeCompare(b.name)); + let notebookEntry: (typeof notebookEntries)[number]; + switch (notebookEntries.length) { + case 0: + log.warn( + `Unit "${unit.id}" has no .ipynb notebook in ${unitDir.fsPath}.`, + ); + return { activities: [] }; + case 1: + notebookEntry = notebookEntries[0]; + break; + default: + notebookEntry = notebookEntries[0]; + log.warn( + `Unit "${unit.id}" has no multiple .ipynb notebooks in ${unitDir.fsPath} - using ${notebookEntry.name}.`, + ); + return { activities: [] }; } const notebookRel = `${unit.dir}/${notebookEntry.name}`; From 96af1ac95453bbd74c232efcccec038a23a2503f Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 11:12:54 -0700 Subject: [PATCH 073/101] Warn when course isn't found --- source/vscode/src/learning/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index dbfe81d6d69..ff7d1770916 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -470,7 +470,7 @@ async function showCourseInfo( const courses = await service.getCourses(); const descriptor = courses.find((c) => c.id === courseId); if (!descriptor) { - // TODO (acasey): log + log.warn(`Unable to show course info for unknown course ${courseId}`); return; } if (descriptor.readmePath) { From 7c5cb407692e094387dc93b2b16dd758855a8612 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 11:13:40 -0700 Subject: [PATCH 074/101] Extract fsUtils --- .../src/learning/dropInCourseProvider.ts | 10 +------ source/vscode/src/learning/fsUtils.ts | 24 +++++++++++++++++ .../src/learning/python/pythonRunner.ts | 19 +------------- source/vscode/src/learning/service.ts | 26 +++---------------- 4 files changed, 30 insertions(+), 49 deletions(-) create mode 100644 source/vscode/src/learning/fsUtils.ts diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index cb14cb998eb..5ee90c84f80 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -13,6 +13,7 @@ import { WORKBOOK_SUFFIX, } from "./constants.js"; import type { CourseProvider } from "./courseProvider.js"; +import { uriExists } from "./fsUtils.js"; import { parseNotebookExercises } from "./notebookExercises.js"; import type { CatalogActivity, @@ -372,12 +373,3 @@ async function tryReadText(uri: vscode.Uri): Promise { return undefined; } } - -async function uriExists(uri: vscode.Uri): Promise { - try { - await vscode.workspace.fs.stat(uri); - return true; - } catch { - return false; - } -} diff --git a/source/vscode/src/learning/fsUtils.ts b/source/vscode/src/learning/fsUtils.ts new file mode 100644 index 00000000000..386fec2018d --- /dev/null +++ b/source/vscode/src/learning/fsUtils.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; + +/** True if something exists at the given URI. */ +export async function uriExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} + +/** Create the containing directory of a file URI, if it doesn't already exist. */ +export async function ensureParentDir(fileUri: vscode.Uri): Promise { + const parentUri = vscode.Uri.joinPath(fileUri, ".."); + try { + await vscode.workspace.fs.createDirectory(parentUri); + } catch { + // already exists + } +} diff --git a/source/vscode/src/learning/python/pythonRunner.ts b/source/vscode/src/learning/python/pythonRunner.ts index 3b761f5a1ae..c245ac994c2 100644 --- a/source/vscode/src/learning/python/pythonRunner.ts +++ b/source/vscode/src/learning/python/pythonRunner.ts @@ -4,6 +4,7 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { WORKBOOK_SUFFIX } from "../constants.js"; +import { ensureParentDir, uriExists } from "../fsUtils.js"; import { stripAuthoringCells } from "../notebookExercises.js"; import type { CatalogCourse } from "../types.js"; @@ -191,21 +192,3 @@ export class PythonCourseRunner { function toWorkbookRel(notebookRel: string): string { return notebookRel.replace(/\.ipynb$/i, WORKBOOK_SUFFIX); } - -async function uriExists(uri: vscode.Uri): Promise { - try { - await vscode.workspace.fs.stat(uri); - return true; - } catch { - return false; - } -} - -async function ensureParentDir(fileUri: vscode.Uri): Promise { - const parentUri = vscode.Uri.joinPath(fileUri, ".."); - try { - await vscode.workspace.fs.createDirectory(parentUri); - } catch { - // already exists - } -} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 00fdf5f60b2..b683f65207f 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -18,6 +18,7 @@ import { LEARNING_WORKSPACE_FOLDER, LEARNING_WORKSPACE_RELATIVE_PATH, } from "./constants.js"; +import { ensureParentDir, uriExists } from "./fsUtils.js"; import type { ActionGroup, ActivityContent, @@ -1998,10 +1999,10 @@ export class LearningService { kata.id, `${activity.id}.qs`, ); - if (await this.uriExists(fileUri)) { + if (await uriExists(fileUri)) { continue; } - await this.ensureParentDir(fileUri); + await ensureParentDir(fileUri); await vscode.workspace.fs.writeFile( fileUri, new TextEncoder().encode(activity.placeholderCode), @@ -2013,7 +2014,7 @@ export class LearningService { kata.id, `${activity.example.id}.qs`, ); - await this.ensureParentDir(fileUri); + await ensureParentDir(fileUri); await vscode.workspace.fs.writeFile( fileUri, new TextEncoder().encode(activity.example.code), @@ -2022,23 +2023,4 @@ export class LearningService { } } } - - // TODO (acasey): check for clones - private async uriExists(uri: vscode.Uri): Promise { - try { - await vscode.workspace.fs.stat(uri); - return true; - } catch { - return false; - } - } - - private async ensureParentDir(fileUri: vscode.Uri): Promise { - const parentUri = vscode.Uri.joinPath(fileUri, ".."); - try { - await vscode.workspace.fs.createDirectory(parentUri); - } catch { - // already exists - } - } } From ace2d7c12e4a466b91af1c46f6058ad57e323dcf Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 11:26:22 -0700 Subject: [PATCH 075/101] Combine some notebook cleanup code --- source/vscode/src/learning/service.ts | 67 ++++++++++++++------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index b683f65207f..b0862b72367 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1584,18 +1584,14 @@ export class LearningService { } /** - * Close any open editor or notebook tabs under the QDK Learning root that - * don't match {@link keepUri}. When {@link keepUri} is undefined, all such - * tabs are closed. + * Close every open text or notebook tab whose URI matches {@link predicate}. + * Tabs backed by any other input kind (diff views, webviews, terminals) are + * skipped, since they have no single URI to match against. */ - async closeStaleEditorTabs(keepUri: vscode.Uri | undefined): Promise { - if (!this.workspace) { - return; - } - const learningRoot = this.learningContentRoot.toString(); - const keepStr = keepUri?.toString(); - - const staleTabs: vscode.Tab[] = []; + private async closeTabs( + predicate: (uri: vscode.Uri, tab: vscode.Tab) => boolean, + ): Promise { + const matches: vscode.Tab[] = []; for (const group of vscode.window.tabGroups.all) { for (const tab of group.tabs) { const input = tab.input; @@ -1604,20 +1600,34 @@ export class LearningService { input instanceof vscode.TabInputNotebook ? input.uri : undefined; - if (!tabUri) { - continue; - } - const tabUriStr = tabUri.toString(); - if (tabUriStr.startsWith(learningRoot) && tabUriStr !== keepStr) { - staleTabs.push(tab); + if (tabUri && predicate(tabUri, tab)) { + matches.push(tab); } } } - if (staleTabs.length > 0) { - await vscode.window.tabGroups.close(staleTabs); + if (matches.length > 0) { + await vscode.window.tabGroups.close(matches); } } + /** + * Close any open editor or notebook tabs under the QDK Learning root that + * don't match {@link keepUri}. When {@link keepUri} is undefined, all such + * tabs are closed. + */ + async closeStaleEditorTabs(keepUri: vscode.Uri | undefined): Promise { + if (!this.workspace) { + return; + } + const learningRoot = this.learningContentRoot.toString(); + const keepStr = keepUri?.toString(); + + await this.closeTabs((uri) => { + const uriStr = uri.toString(); + return uriStr.startsWith(learningRoot) && uriStr !== keepStr; + }); + } + /** Turns a catalog activity into the typed content payload (exercise, lesson-example, or lesson-text). */ private resolveActivityContent( location: ActivityLocation, @@ -1956,20 +1966,11 @@ export class LearningService { */ private async closeNotebookTab(uri: vscode.Uri): Promise { const uriStr = uri.toString(); - const tabs: vscode.Tab[] = []; - for (const group of vscode.window.tabGroups.all) { - for (const tab of group.tabs) { - if ( - tab.input instanceof vscode.TabInputNotebook && - tab.input.uri.toString() === uriStr - ) { - tabs.push(tab); - } - } - } - if (tabs.length > 0) { - await vscode.window.tabGroups.close(tabs); - } + await this.closeTabs( + (tabUri, tab) => + tab.input instanceof vscode.TabInputNotebook && + tabUri.toString() === uriStr, + ); } /** From a1c1a0f0d9c528f7b8575a363e7c269e474b7414 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 11:37:16 -0700 Subject: [PATCH 076/101] Clean up TODOs --- source/vscode/src/learning/panel.ts | 2 +- source/vscode/src/learning/service.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index cd5d0fe3faf..18b2ca6941b 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -319,7 +319,7 @@ export class LessonPanelManager { } if (msg.command === "browseCourses") { - // TODO (acasey): was this supposed to be list courses? + // TODO (acasey): we might want to rename some of the commands and tools for consistency await vscode.commands.executeCommand( "qsharp-vscode.learningSwitchCourse", ); diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index b0862b72367..44266ba2541 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -423,7 +423,7 @@ export class LearningService { const unit = this.findUnit(this.position.unitId); const exercise = unit.notebookExercises?.find((e) => e.cellId === cellId); if (!exercise) { - // TODO (acasey): log unknown exercise + log.warn(`Unable to find exercise corresponding to cell ${cellId}`); return false; } const location: ActivityLocation = { @@ -646,7 +646,6 @@ export class LearningService { async applyEnvironmentCheckFix(fix: EnvironmentCheckFix): Promise { switch (fix.kind) { case "setup": - // TODO (acasey): should this be a command? await this.setupActiveEnvironment(); return; case "install-extensions": @@ -1425,7 +1424,7 @@ export class LearningService { const descriptors = await registry.listCourses(); for (const descriptor of descriptors) { try { - // TODO (acasey): other code (and Mine) mentioned doing this lazily + // TODO (acasey): parsing all courses seems fine, but we probably only want to materialize the active one const course = await registry.loadCourse(descriptor.id); courses.set(course.id, course); } catch { @@ -1774,6 +1773,10 @@ export class LearningService { this._onDidChangeState.fire(this.getState()); // TODO (acasey): do we actually want telemetry for other courses? + // We need to either drop it so that all telemetry is about the katas + // or introduce a new property to distinguish kata telemetry from python telemetry. + // We may want to have an allow-list of known python courses and record others + // as "other" (unless one-way hashing is allowed). const units = this.activeCourse.units; const unitIndex = units.findIndex((u) => u.id === location.unitId); const unit = unitIndex >= 0 ? units[unitIndex] : undefined; From 4bb0dadb71402342b72662502112b76d62a75aa7 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 11:42:53 -0700 Subject: [PATCH 077/101] Extract missing extension helper --- .../src/learning/python/pythonRunner.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/source/vscode/src/learning/python/pythonRunner.ts b/source/vscode/src/learning/python/pythonRunner.ts index c245ac994c2..3e8d090f970 100644 --- a/source/vscode/src/learning/python/pythonRunner.ts +++ b/source/vscode/src/learning/python/pythonRunner.ts @@ -17,6 +17,23 @@ import type { CatalogCourse } from "../types.js"; * readiness checks. */ export class PythonCourseRunner { + /** Extensions required to run `python-notebook` courses. */ + private static readonly REQUIRED_EXTENSIONS: { id: string; name: string }[] = + [ + { id: "ms-python.python", name: "Python" }, + { id: "ms-toolsai.jupyter", name: "Jupyter" }, + ]; + + /** + * Returns the subset of `REQUIRED_EXTENSIONS` that are not currently + * installed. + */ + private getMissingExtensions(): { id: string; name: string }[] { + return PythonCourseRunner.REQUIRED_EXTENSIONS.filter( + (e) => !vscode.extensions.getExtension(e.id), + ); + } + /** * Soft-check that the Python and Jupyter extensions are available. On * VS Code for the Web (where they can't run) returns a desktop-only @@ -29,13 +46,7 @@ export class PythonCourseRunner { "with the Python and Jupyter extensions." ); } - const missing: { id: string; name: string }[] = []; - if (!vscode.extensions.getExtension("ms-python.python")) { - missing.push({ id: "ms-python.python", name: "Python" }); - } - if (!vscode.extensions.getExtension("ms-toolsai.jupyter")) { - missing.push({ id: "ms-toolsai.jupyter", name: "Jupyter" }); - } + const missing = this.getMissingExtensions(); if (missing.length === 0) { return undefined; } @@ -52,11 +63,7 @@ export class PythonCourseRunner { if (vscode.env.uiKind === vscode.UIKind.Web) { return; } - // TODO (acasey): share code with ensureExtensions - const required: { id: string; name: string }[] = [ - { id: "ms-python.python", name: "Python" }, - { id: "ms-toolsai.jupyter", name: "Jupyter" }, - ].filter((e) => !vscode.extensions.getExtension(e.id)); + const required = this.getMissingExtensions(); if (required.length === 0) { return; } From 63efad185bd20de055f569f8d9f5cd8c5b6f20dd Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 12:54:13 -0700 Subject: [PATCH 078/101] Split up pythonRunner --- .../src/learning/python/extensionUtils.ts | 70 ++++++ .../src/learning/python/materialization.ts | 127 +++++++++++ .../src/learning/python/pythonRunner.ts | 201 ------------------ source/vscode/src/learning/service.ts | 36 ++-- 4 files changed, 213 insertions(+), 221 deletions(-) create mode 100644 source/vscode/src/learning/python/extensionUtils.ts create mode 100644 source/vscode/src/learning/python/materialization.ts delete mode 100644 source/vscode/src/learning/python/pythonRunner.ts diff --git a/source/vscode/src/learning/python/extensionUtils.ts b/source/vscode/src/learning/python/extensionUtils.ts new file mode 100644 index 00000000000..9473a73be39 --- /dev/null +++ b/source/vscode/src/learning/python/extensionUtils.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; + +/** Extensions required to run `python-notebook` courses. */ +const REQUIRED_EXTENSIONS: { id: string; name: string }[] = [ + { id: "ms-python.python", name: "Python" }, + { id: "ms-toolsai.jupyter", name: "Jupyter" }, +]; + +/** + * Returns the subset of {@link REQUIRED_EXTENSIONS} that are not currently + * installed. + */ +function getMissingExtensions(): { id: string; name: string }[] { + return REQUIRED_EXTENSIONS.filter( + (e) => !vscode.extensions.getExtension(e.id), + ); +} + +/** + * Soft-check that the Python and Jupyter extensions are available. On + * VS Code for the Web (where they can't run) returns a desktop-only + * message. Returns `undefined` when everything required is present. + */ +export function checkPythonExtensions(): string | undefined { + if (vscode.env.uiKind === vscode.UIKind.Web) { + return ( + "Python notebook courses require the desktop version of VS Code " + + "with the Python and Jupyter extensions." + ); + } + const missing = getMissingExtensions(); + if (missing.length === 0) { + return undefined; + } + return `This course needs the ${missing + .map((m) => m.name) + .join(" and ")} extension${missing.length > 1 ? "s" : ""}.`; +} + +/** + * Prompt the user to install any missing required extensions. Safe to + * call when nothing is missing (it no-ops). + */ +export async function promptInstallPythonExtensions(): Promise { + if (vscode.env.uiKind === vscode.UIKind.Web) { + return; + } + const required = getMissingExtensions(); + if (required.length === 0) { + return; + } + const choice = await vscode.window.showInformationMessage( + `This course needs the ${required + .map((r) => r.name) + .join(" and ")} extension${required.length > 1 ? "s" : ""}.`, + "Install", + ); + if (choice !== "Install") { + return; + } + for (const ext of required) { + await vscode.commands.executeCommand( + "workbench.extensions.installExtension", + ext.id, + ); + } +} diff --git a/source/vscode/src/learning/python/materialization.ts b/source/vscode/src/learning/python/materialization.ts new file mode 100644 index 00000000000..cf448504f91 --- /dev/null +++ b/source/vscode/src/learning/python/materialization.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import { WORKBOOK_SUFFIX } from "../constants.js"; +import { ensureParentDir, uriExists } from "../fsUtils.js"; +import { stripAuthoringCells } from "../notebookExercises.js"; +import type { CatalogCourse } from "../types.js"; + +/** + * Working-copy URI of a unit's notebook: a `*.workbook.ipynb` file that + * sits beside the authored source notebook in the same unit folder. + * + * Keeping the working copy as a sibling means the learner's notebook + * resolves the same relative imports (`_course_lib.py`, `_unit.py`, etc.) as the + * source. + */ +export function workbookFileUri( + course: CatalogCourse, + notebookRel: string, +): vscode.Uri { + if (!course.sourceDir) { + throw new Error(`Course "${course.id}" has no source folder.`); + } + const sourceRoot = vscode.Uri.parse(course.sourceDir); + return vscode.Uri.joinPath(sourceRoot, toWorkbookRel(notebookRel)); +} + +/** + * Materialize the working copy for every unit in the course: derive each + * `*.workbook.ipynb` sibling from the authored notebook. Existing workbooks + * are never overwritten, preserving learner edits. + */ +export async function materializeCourseWorkbooks( + course: CatalogCourse, +): Promise { + if (!course.sourceDir) { + throw new Error(`Course "${course.id}" has no source folder.`); + } + const sourceRoot = vscode.Uri.parse(course.sourceDir); + + for (const unit of course.units) { + if (!unit.notebookRel) { + continue; + } + const dest = vscode.Uri.joinPath( + sourceRoot, + toWorkbookRel(unit.notebookRel), + ); + if (await uriExists(dest)) { + continue; + } + await materializeNotebook( + vscode.Uri.joinPath(sourceRoot, unit.notebookRel), + dest, + unit.id, + ); + } +} + +/** + * Re-materialize a single unit: overwrite its `*.workbook.ipynb` + * with a fresh copy derived from the authored notebook. + */ +export async function rematerializeUnitWorkbook( + course: CatalogCourse, + unitId: string, +): Promise { + if (!course.sourceDir) { + throw new Error(`Course "${course.id}" has no source folder.`); + } + const unit = course.units.find((u) => u.id === unitId); + if (!unit?.notebookRel) { + throw new Error(`Unit "${unitId}" not found in course "${course.id}".`); + } + + const sourceRoot = vscode.Uri.parse(course.sourceDir); + await materializeNotebook( + vscode.Uri.joinPath(sourceRoot, unit.notebookRel), + vscode.Uri.joinPath(sourceRoot, toWorkbookRel(unit.notebookRel)), + unit.id, + ); +} + +/** + * Write a unit's working copy: the authored notebook minus its author-only + * cells (hints, solutions, explanations). + * + * If the notebook can't be parsed we fall back to copying it verbatim, so a + * malformed notebook still leaves the learner with something to work in + * rather than nothing. + */ +async function materializeNotebook( + src: vscode.Uri, + dest: vscode.Uri, + unitId: string, +): Promise { + try { + await ensureParentDir(dest); + const text = new TextDecoder().decode( + await vscode.workspace.fs.readFile(src), + ); + const stripped = stripAuthoringCells(text, unitId); + if (stripped === undefined) { + await vscode.workspace.fs.copy(src, dest, { overwrite: true }); + return; + } + await vscode.workspace.fs.writeFile( + dest, + new TextEncoder().encode(stripped), + ); + } catch (e) { + log.warn( + `Failed to materialize ${src.fsPath} → ${dest.fsPath}: ${String(e)}`, + ); + } +} + +/** + * Map a source notebook's relative path to its working-copy sibling by + * swapping the `.ipynb` extension for `.workbook.ipynb` + * (e.g. `01-intro/intro.ipynb` → `01-intro/intro.workbook.ipynb`). + */ +function toWorkbookRel(notebookRel: string): string { + return notebookRel.replace(/\.ipynb$/i, WORKBOOK_SUFFIX); +} diff --git a/source/vscode/src/learning/python/pythonRunner.ts b/source/vscode/src/learning/python/pythonRunner.ts deleted file mode 100644 index 3e8d090f970..00000000000 --- a/source/vscode/src/learning/python/pythonRunner.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { log } from "qsharp-lang"; -import * as vscode from "vscode"; -import { WORKBOOK_SUFFIX } from "../constants.js"; -import { ensureParentDir, uriExists } from "../fsUtils.js"; -import { stripAuthoringCells } from "../notebookExercises.js"; -import type { CatalogCourse } from "../types.js"; - -// TODO (acasey): rename this - -/** - * Manages `python-notebook` course files. All Jupyter/notebook execution - * is handled by VS Code's native notebook UI — this class only handles - * materialization (copying course source to a working copy) and extension - * readiness checks. - */ -export class PythonCourseRunner { - /** Extensions required to run `python-notebook` courses. */ - private static readonly REQUIRED_EXTENSIONS: { id: string; name: string }[] = - [ - { id: "ms-python.python", name: "Python" }, - { id: "ms-toolsai.jupyter", name: "Jupyter" }, - ]; - - /** - * Returns the subset of `REQUIRED_EXTENSIONS` that are not currently - * installed. - */ - private getMissingExtensions(): { id: string; name: string }[] { - return PythonCourseRunner.REQUIRED_EXTENSIONS.filter( - (e) => !vscode.extensions.getExtension(e.id), - ); - } - - /** - * Soft-check that the Python and Jupyter extensions are available. On - * VS Code for the Web (where they can't run) returns a desktop-only - * message. Returns `undefined` when everything required is present. - */ - async ensureExtensions(): Promise { - if (vscode.env.uiKind === vscode.UIKind.Web) { - return ( - "Python notebook courses require the desktop version of VS Code " + - "with the Python and Jupyter extensions." - ); - } - const missing = this.getMissingExtensions(); - if (missing.length === 0) { - return undefined; - } - return `This course needs the ${missing - .map((m) => m.name) - .join(" and ")} extension${missing.length > 1 ? "s" : ""}.`; - } - - /** - * Prompt the user to install any missing required extensions. Safe to - * call when nothing is missing (it no-ops). - */ - async promptInstallExtensions(): Promise { - if (vscode.env.uiKind === vscode.UIKind.Web) { - return; - } - const required = this.getMissingExtensions(); - if (required.length === 0) { - return; - } - const choice = await vscode.window.showInformationMessage( - `This course needs the ${required - .map((r) => r.name) - .join(" and ")} extension${required.length > 1 ? "s" : ""}.`, - "Install", - ); - if (choice !== "Install") { - return; - } - for (const ext of required) { - await vscode.commands.executeCommand( - "workbench.extensions.installExtension", - ext.id, - ); - } - } - - /** - * Working-copy URI of a unit's notebook: a `*.workbook.ipynb` file that - * sits beside the authored source notebook in the same unit folder. - * - * Keeping the working copy as a sibling means the learner's notebook - * resolves the same relative imports (`_course_lib.py`, `_unit.py`, etc.) as the - * source. - */ - workbookFileUri(course: CatalogCourse, notebookRel: string): vscode.Uri { - if (!course.sourceDir) { - throw new Error(`Course "${course.id}" has no source folder.`); - } - const sourceRoot = vscode.Uri.parse(course.sourceDir); - return vscode.Uri.joinPath(sourceRoot, toWorkbookRel(notebookRel)); - } - - /** - * Materialize the working copy for every unit in the course: derive each - * `*.workbook.ipynb` sibling from the authored notebook. Existing workbooks - * are never overwritten, preserving learner edits. - */ - async materializeCourse(course: CatalogCourse): Promise { - if (!course.sourceDir) { - throw new Error(`Course "${course.id}" has no source folder.`); - } - const sourceRoot = vscode.Uri.parse(course.sourceDir); - - for (const unit of course.units) { - if (!unit.notebookRel) { - continue; - } - const dest = vscode.Uri.joinPath( - sourceRoot, - toWorkbookRel(unit.notebookRel), - ); - if (await uriExists(dest)) { - continue; - } - await this.materializeNotebook( - vscode.Uri.joinPath(sourceRoot, unit.notebookRel), - dest, - unit.id, - ); - } - } - - /** - * Re-materialize a single unit: overwrite its `*.workbook.ipynb` - * with a fresh copy derived from the authored notebook. - */ - async rematerializeUnit( - course: CatalogCourse, - unitId: string, - ): Promise { - if (!course.sourceDir) { - throw new Error(`Course "${course.id}" has no source folder.`); - } - const unit = course.units.find((u) => u.id === unitId); - if (!unit?.notebookRel) { - throw new Error(`Unit "${unitId}" not found in course "${course.id}".`); - } - - const sourceRoot = vscode.Uri.parse(course.sourceDir); - await this.materializeNotebook( - vscode.Uri.joinPath(sourceRoot, unit.notebookRel), - vscode.Uri.joinPath(sourceRoot, toWorkbookRel(unit.notebookRel)), - unit.id, - ); - } - - /** - * Write a unit's working copy: the authored notebook minus its author-only - * cells (hints, solutions, explanations). - * - * If the notebook can't be parsed we fall back to copying it verbatim, so a - * malformed notebook still leaves the learner with something to work in - * rather than nothing. - */ - private async materializeNotebook( - src: vscode.Uri, - dest: vscode.Uri, - unitId: string, - ): Promise { - try { - await ensureParentDir(dest); - const text = new TextDecoder().decode( - await vscode.workspace.fs.readFile(src), - ); - const stripped = stripAuthoringCells(text, unitId); - if (stripped === undefined) { - await vscode.workspace.fs.copy(src, dest, { overwrite: true }); - return; - } - await vscode.workspace.fs.writeFile( - dest, - new TextEncoder().encode(stripped), - ); - } catch (e) { - log.warn( - `Failed to materialize ${src.fsPath} → ${dest.fsPath}: ${String(e)}`, - ); - } - } -} - -// ─── Helpers ─── - -/** - * Map a source notebook's relative path to its working-copy sibling by - * swapping the `.ipynb` extension for `.workbook.ipynb` - * (e.g. `01-intro/intro.ipynb` → `01-intro/intro.workbook.ipynb`). - */ -function toWorkbookRel(notebookRel: string): string { - return notebookRel.replace(/\.ipynb$/i, WORKBOOK_SUFFIX); -} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 44266ba2541..14a836a8d7e 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -10,7 +10,15 @@ import { EventType, sendTelemetryEvent } from "../telemetry.js"; import { createCourseRegistry } from "./catalog.js"; import { CourseRegistry } from "./courseProvider.js"; import { EnvironmentManager } from "./python/environment.js"; -import { PythonCourseRunner } from "./python/pythonRunner.js"; +import { + checkPythonExtensions, + promptInstallPythonExtensions, +} from "./python/extensionUtils.js"; +import { + materializeCourseWorkbooks, + rematerializeUnitWorkbook, + workbookFileUri, +} from "./python/materialization.js"; import { KATAS_COURSE_ID, LEARNING_FILE, @@ -142,7 +150,6 @@ export class LearningService { private _writingProgress = false; private _initPromise: Promise | undefined; private readonly _disposables: vscode.Disposable[] = []; - private _pythonRunner: PythonCourseRunner | undefined; private _environment: EnvironmentManager | undefined; constructor(private readonly extensionUri: vscode.Uri) { @@ -169,14 +176,6 @@ export class LearningService { return this.requireWorkspace().workspaceRoot; } - /** Lazily-created runner for `python-notebook` courses. */ - private get pythonRunner(): PythonCourseRunner { - if (!this._pythonRunner) { - this._pythonRunner = new PythonCourseRunner(); - } - return this._pythonRunner; - } - /** Lazily-created per-course Python environment manager. */ private get environment(): EnvironmentManager { if (!this._environment) { @@ -496,10 +495,7 @@ export class LearningService { if (!unit.notebookRel) { continue; } - const workbook = this.pythonRunner.workbookFileUri( - course, - unit.notebookRel, - ); + const workbook = workbookFileUri(course, unit.notebookRel); if (workbook.toString() === target) { return { course, unit }; } @@ -649,7 +645,7 @@ export class LearningService { await this.setupActiveEnvironment(); return; case "install-extensions": - await this.pythonRunner.promptInstallExtensions(); + await promptInstallPythonExtensions(); return; } } @@ -714,7 +710,7 @@ export class LearningService { // 1. Required extensions (Python + Jupyter). log.info(`[env-check] Checking extensions…`); - const extMessage = await this.pythonRunner.ensureExtensions(); + const extMessage = checkPythonExtensions(); log.info(`[env-check] Extensions: ${extMessage ?? "ok"}`); checks.push( check( @@ -859,7 +855,7 @@ export class LearningService { await this.scaffoldCourse(ws, course); } if (course.kind === "python-notebook") { - void this.pythonRunner.promptInstallExtensions(); + void promptInstallPythonExtensions(); void this.ensureEnvironment(course); } ws.progressData.position = this.firstIncompletePosition(course); @@ -1105,7 +1101,7 @@ export class LearningService { await this.closeNotebookTab(notebookUri); } // Re-materialize the unit from source. - await this.pythonRunner.rematerializeUnit(this.activeCourse, unit.id); + await rematerializeUnitWorkbook(this.activeCourse, unit.id); // Clear completion for every activity in the unit, not just the // current one, since the whole unit was re-materialized. this.markUnitIncomplete(this.activeCourse.id, unit); @@ -1689,7 +1685,7 @@ export class LearningService { /** Working-copy (`*.workbook.ipynb`) URI of a notebook for the active python-notebook course. */ private notebookFileUri(notebookRel: string): vscode.Uri { - return this.pythonRunner.workbookFileUri(this.activeCourse, notebookRel); + return workbookFileUri(this.activeCourse, notebookRel); } private findCurrentActivity(): { @@ -1988,7 +1984,7 @@ export class LearningService { if (course.kind === "python-notebook") { // Copy the course's notebooks into the workspace working copy so the // learner edits a stable location, then surface any missing tooling. - await this.pythonRunner.materializeCourse(course); + await materializeCourseWorkbooks(course); return; } if (course.kind !== "qsharp") { From 207e60294301be949ec05eceb8b35b9aa3905f21 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 13:00:56 -0700 Subject: [PATCH 079/101] Drop printf logging from env check --- source/vscode/src/learning/service.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 14a836a8d7e..7fdcf9aac37 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -660,12 +660,8 @@ export class LearningService { */ async runEnvironmentCheck(): Promise { const course = this.activeCourse; - log.info( - `[env-check] Starting for course "${course.id}" (kind=${course.kind})`, - ); if (course.kind !== "python-notebook") { - log.info(`[env-check] Q# course — skipping environment checks.`); const checks: EnvironmentCheckItem[] = [ check("course-kind", "Course type", "ok", { detail: "Q# course — runs on the built-in simulator.", @@ -681,9 +677,6 @@ export class LearningService { // Hard stop: environment management can't run on the Web. if (!env.supported) { - log.info( - `[env-check] Environment management unavailable in current editor.`, - ); const checks: EnvironmentCheckItem[] = [ check("host", "Desktop VS Code", "fail", { detail: "Python courses require the desktop version of VS Code.", @@ -696,7 +689,6 @@ export class LearningService { // Resolve the course's working root (its source folder); the venv // lives here, beside the authored notebooks. if (!course.sourceDir) { - log.info(`[env-check] No sourceDir — cannot resolve course root.`); return this.assembleReport(course, [ check("course-folder", "Course folder", "fail", { detail: "This course has no source folder on disk.", @@ -704,14 +696,11 @@ export class LearningService { ]); } const courseRoot = vscode.Uri.parse(course.sourceDir); - log.info(`[env-check] Course root: ${courseRoot.fsPath}`); const checks: EnvironmentCheckItem[] = []; // 1. Required extensions (Python + Jupyter). - log.info(`[env-check] Checking extensions…`); const extMessage = checkPythonExtensions(); - log.info(`[env-check] Extensions: ${extMessage ?? "ok"}`); checks.push( check( "extensions", @@ -730,9 +719,7 @@ export class LearningService { ); // 2. The per-course environment. - log.info(`[env-check] Checking environment existence…`); const envExists = await env.environmentExists(courseRoot); - log.info(`[env-check] Environment exists: ${envExists}`); checks.push( check("venv", "Course environment", envExists ? "ok" : "fail", { detail: envExists @@ -750,12 +737,8 @@ export class LearningService { // 3. Required packages import in the environment. const importChecks = course.environment?.importChecks ?? []; if (envExists && importChecks.length > 0) { - log.info(`[env-check] Checking package imports…`); const report = await env.importsReport(courseRoot, importChecks); const missing = report.filter((r) => !r.ok).map((r) => r.module); - log.info( - `[env-check] Import results: ${report.map((r) => `${r.module}=${r.ok ? "ok" : "fail"}`).join(", ")}`, - ); checks.push( check( "packages", @@ -778,7 +761,6 @@ export class LearningService { ), ); } else if (importChecks.length > 0) { - log.info(`[env-check] Skipping package imports — no environment.`); checks.push( check("packages", "Required packages", "skip", { detail: "No environment yet.", @@ -786,7 +768,6 @@ export class LearningService { ); } - log.info(`[env-check] Assembling report (${checks.length} checks).`); return this.assembleReport(course, checks); } From a0c1871e4e8177f97ecdbe0e08aee7955302b1a6 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 13:24:09 -0700 Subject: [PATCH 080/101] Await extension installation and add some TODOs --- source/vscode/src/learning/service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 7fdcf9aac37..96102de4d16 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -836,7 +836,9 @@ export class LearningService { await this.scaffoldCourse(ws, course); } if (course.kind === "python-notebook") { - void promptInstallPythonExtensions(); + // Need to await extension installation since environment setup depends + // on the Python Environments extension + await promptInstallPythonExtensions(); void this.ensureEnvironment(course); } ws.progressData.position = this.firstIncompletePosition(course); @@ -1344,6 +1346,7 @@ export class LearningService { detected.learningContentRoot, ); this.startWatcher(); + // TODO (acasey): make sure we're firing this an appropriate number of times sendTelemetryEvent( EventType.LearningSessionStarted, { isFirstTime: "false" }, @@ -1444,6 +1447,7 @@ export class LearningService { await this.scaffoldCourse(ws, course); } catch { // A failing scaffold should not block workspace initialization. + // TODO (acasey): log } } } From 8809c72676fac719517450998fb065cddf3af230 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 13:35:05 -0700 Subject: [PATCH 081/101] Inline setupActiveEnvironment --- source/vscode/src/learning/service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 96102de4d16..dc59c259360 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -613,11 +613,6 @@ export class LearningService { ); } - /** Set up the environment for the currently-active course. */ - async setupActiveEnvironment(): Promise { - await this.ensureEnvironment(this.activeCourse, { force: true }); - } - /** * Return the `{ id, path }` for the active course's Python environment, * suitable for passing to the Jupyter extension's `openNotebook` API. @@ -642,7 +637,7 @@ export class LearningService { async applyEnvironmentCheckFix(fix: EnvironmentCheckFix): Promise { switch (fix.kind) { case "setup": - await this.setupActiveEnvironment(); + await this.ensureEnvironment(this.activeCourse, { force: true }); return; case "install-extensions": await promptInstallPythonExtensions(); From 5bde83050235643470d0804281bb1a2d049096aa Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 14:43:01 -0700 Subject: [PATCH 082/101] Address review feedback --- source/vscode/src/learning/commands.ts | 3 +- .../src/learning/dropInCourseProvider.ts | 2 +- source/vscode/src/learning/index.ts | 19 +++++------ .../vscode/src/learning/notebookExercises.ts | 1 + .../vscode/src/learning/python/environment.ts | 27 +--------------- .../src/learning/python/extensionUtils.ts | 2 ++ source/vscode/src/learning/service.ts | 32 ++----------------- source/vscode/src/learning/types.d.ts | 2 +- 8 files changed, 20 insertions(+), 68 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index ff7d1770916..a940fefd7f0 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -283,8 +283,7 @@ function resolveCellId( } /** - * Open the current unit's notebook working copy, pre-selecting the course's - * Python environment as the active kernel. + * Open the current unit's notebook working copy. * * By default this reveals the current exercise cell; pass `reveal: "top"` to * start at the beginning of the notebook instead. diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 5ee90c84f80..7411c046583 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -263,7 +263,7 @@ export class DropInCourseProvider implements CourseProvider { log.warn( `Unit "${unit.id}" has no multiple .ipynb notebooks in ${unitDir.fsPath} - using ${notebookEntry.name}.`, ); - return { activities: [] }; + break; } const notebookRel = `${unit.dir}/${notebookEntry.name}`; diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts index 70c4d3bcca5..ec59720df83 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -43,6 +43,8 @@ export function initLearning( ); context.subscriptions.push( vscode.workspace.onDidChangeNotebookDocument((e) => { + // TODO (acasey): move to notebookSync.ts? + // When a cell finishes executing (executionSummary changes), auto-save // the notebook, check if it corresponds to an exercise in the active // python-notebook course and update focus. If execution succeeded, @@ -53,18 +55,11 @@ export function initLearning( ) { return; } - const hasExecutionChange = e.cellChanges.some( - (change) => change.executionSummary !== undefined, - ); - if (hasExecutionChange) { - // Moving between notebooks is clumsy when they're unsaved. Since this - // is a working copy we created on the user's behalf, we're free to - // auto-save. - void e.notebook.save(); - } + let hasExecutionChange = false; for (const change of e.cellChanges) { if (change.executionSummary !== undefined) { + hasExecutionChange = true; const cellId = change.cell.metadata?.id; if (typeof cellId !== "string") { continue; @@ -75,6 +70,12 @@ export function initLearning( } } } + if (hasExecutionChange) { + // Moving between notebooks is clumsy when they're unsaved. Since this + // is a working copy we created on the user's behalf, we're free to + // auto-save. + void e.notebook.save(); + } }), ); registerLearningProgressView(context, learningService); diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts index f56f61ac862..0ffa29dd1a9 100644 --- a/source/vscode/src/learning/notebookExercises.ts +++ b/source/vscode/src/learning/notebookExercises.ts @@ -175,6 +175,7 @@ export function parseNotebookExercises( return exercises; } +// TODO (acasey): share code with readCells /** * Remove the author-only cells from a notebook's JSON text, returning the * notebook the learner works in. diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index f0372b314a5..7c4361d21ed 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -64,7 +64,7 @@ export class EnvironmentManager { if (env) { log.info( - `Updating existing environment for ${courseRoot.fsPath}: ${env.name}`, + `Using existing environment for ${courseRoot.fsPath}: ${env.name}`, ); } else { // Create a new environment. The API picks up requirements.txt, if present. @@ -107,31 +107,6 @@ export class EnvironmentManager { return env !== undefined; } - /** - * Return the `{ id, path }` for the course's Python environment, suitable - * for passing to the Jupyter extension's `openNotebook` API. - * Returns `undefined` when no environment has been resolved. - */ - async getJupyterEnvironmentPath( - courseRoot: vscode.Uri, - ): Promise<{ id: string; path: string } | undefined> { - if (!this.supported) { - return undefined; - } - const api = await this.pythonEnvironmentsApi(); - if (!api) { - return undefined; - } - const env = await this.findEnvironment(api, courseRoot); - if (!env) { - return undefined; - } - return { - id: env.envId.id, - path: env.environmentPath.fsPath, - }; - } - /** * Per-module import report for the course environment. Each entry is * `true` when that module imports successfully. Missing environment yields diff --git a/source/vscode/src/learning/python/extensionUtils.ts b/source/vscode/src/learning/python/extensionUtils.ts index 9473a73be39..89713cbe91f 100644 --- a/source/vscode/src/learning/python/extensionUtils.ts +++ b/source/vscode/src/learning/python/extensionUtils.ts @@ -40,6 +40,8 @@ export function checkPythonExtensions(): string | undefined { .join(" and ")} extension${missing.length > 1 ? "s" : ""}.`; } +// TODO (acasey): there's no real reason to prompt here if it's only reachable from +// the environment check dialog and the user already clicked a button. /** * Prompt the user to install any missing required extensions. Safe to * call when nothing is missing (it no-ops). diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index dc59c259360..67980ec3d1e 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -184,19 +184,6 @@ export class LearningService { return this._environment; } - /** - * Re-scan available courses (e.g. after a new drop-in course is added). - * Drop-in courses are enumerated lazily by the registry, so this just - * refreshes the UI to pick up newly-added folders. - */ - async reloadCourses(): Promise { - if (!this.workspace) { - return; - } - this.emitProgress(); - this._onDidChangeState.fire(this.getState()); - } - /** * Try to initialize the service. Returns `true` when ready, `false` * when no learning workspace could be found (or created). @@ -529,6 +516,7 @@ export class LearningService { }; } + // TODO (acasey): isExerciseCellId /** * Returns the set of cell IDs that correspond to exercises in the * current unit. Empty if the course isn't a python-notebook course or @@ -613,22 +601,6 @@ export class LearningService { ); } - /** - * Return the `{ id, path }` for the active course's Python environment, - * suitable for passing to the Jupyter extension's `openNotebook` API. - * Returns `undefined` for Q# courses or when no environment exists. - */ - async getJupyterEnvironmentPath(): Promise< - { id: string; path: string } | undefined - > { - const course = this.activeCourse; - if (course.kind !== "python-notebook" || !course.sourceDir) { - return undefined; - } - const courseRoot = vscode.Uri.parse(course.sourceDir); - return this.environment.getJupyterEnvironmentPath(courseRoot); - } - /** * Apply a fix surfaced by {@link runEnvironmentCheck}. Centralizes the * mapping from an {@link EnvironmentCheckFix.kind} to a concrete action so @@ -852,6 +824,7 @@ export class LearningService { */ private firstIncompletePosition(course: CatalogCourse): ActivityLocation { for (const unit of course.units) { + // TODO (acasey): reuse firstIncompleteInUnit for (const activity of unit.activities) { const location: ActivityLocation = { courseId: course.id, @@ -1400,6 +1373,7 @@ export class LearningService { for (const descriptor of descriptors) { try { // TODO (acasey): parsing all courses seems fine, but we probably only want to materialize the active one + // TODO (acasey): this shouldn't redo discovery for each course const course = await registry.loadCourse(descriptor.id); courses.set(course.id, course); } catch { diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index 88618748a1d..2ee1837745e 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -311,7 +311,7 @@ export interface CatalogCourse { * URI string of the folder the course was loaded from (drop-in courses * only). Used to locate notebooks and other assets for materialization. */ - sourceDir?: string; + sourceDir?: string; // TODO (acasey): vscode.Uri? /** Environment requirements (python-notebook courses). */ environment?: CourseEnvironment; } From 790cc32ea97d8f93a00445848a6ce5b68eb47e71 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 15:13:32 -0700 Subject: [PATCH 083/101] Clean up unused field --- source/vscode/src/learning/service.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 67980ec3d1e..20a0d65832d 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -145,7 +145,6 @@ export class LearningService { >(); readonly onDidChangeProgress = this._onDidChangeProgress.event; - private _lastSnapshot: OverallProgress | undefined; private _progressFileWatcher: vscode.FileSystemWatcher | undefined; private _writingProgress = false; private _initPromise: Promise | undefined; @@ -213,6 +212,8 @@ export class LearningService { return true; } // The in-flight attempt didn't create — fall through to retry. + // TODO (acasey): this retry isn't safe if A wins the initial race, leaving B and C waiting, + // and then fails to actually initialize, B and C will race to call detectAndLoadWorkspace. } this._initPromise = this.detectAndLoadWorkspace(options).finally(() => { @@ -1792,6 +1793,7 @@ export class LearningService { // expected when file is missing or corrupt } const course = this.defaultCourseOf(ws); + // TODO (acasey): is this identical to what was passed in? ws.progressData = { version: 1, position: { @@ -1890,7 +1892,6 @@ export class LearningService { // File removed externally — tear down all workspace state. this.workspace = undefined; this.syncContextKey(); - this._lastSnapshot = undefined; this._onDidChangeProgress.fire(undefined); }; @@ -1906,12 +1907,10 @@ export class LearningService { private emitProgress(): void { if (!this.workspace) { - this._lastSnapshot = undefined; this._onDidChangeProgress.fire(undefined); return; } - this._lastSnapshot = this.getProgress(); - this._onDidChangeProgress.fire(this._lastSnapshot); + this._onDidChangeProgress.fire(this.getProgress()); } /** From 14656ffb542130204599f20ad54eca873cecf22b Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 15:43:14 -0700 Subject: [PATCH 084/101] Handle a race in panel message passing --- source/vscode/src/learning/panel.ts | 51 +++++++++++++++++++-------- source/vscode/src/learning/service.ts | 15 -------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index 18b2ca6941b..cdf41a4ed48 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -7,11 +7,12 @@ * the learning feature. */ +import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { qsharpExtensionId } from "../common.js"; import { LEARNING_FILE, LEARNING_TREE_VIEW_ID } from "./constants.js"; import type { LearningService } from "./service.js"; -import type { TelemetrySource } from "./types.js"; +import type { LearningState, TelemetrySource } from "./types.js"; import type { HostToWebviewMessage, ResultAction, @@ -205,14 +206,34 @@ export class LessonPanelManager { } } + /** + * The state payload to attach to a webview message, or `undefined` when + * there is no panel to send it to. + * + * Every message carrying state must resolve it through here, because the + * panel check has to happen *before* the message is built. + * {@link sendMessage}'s own guard runs too late: by then the state argument + * has already been evaluated. + */ + private panelState(): LearningState | undefined { + if (!this.panel || !this.service.initialized) { + return undefined; + } + if (this.isPythonNotebook) { + // The panel disposes itself as soon as the service switches into a + // course that doesn't use it, so this is not expected to happen. + log.warn("The lesson panel is not used for python-notebook courses."); + return undefined; + } + return this.service.getState(); + } + private sendState(): void { - if (!this.service.initialized) { + const state = this.panelState(); + if (!state) { return; } - this.sendMessage({ - command: "state", - state: this.service.getStateForPanel(), - }); + this.sendMessage({ command: "state", state }); } /** @@ -253,14 +274,15 @@ export class LessonPanelManager { action: Action, result: ResultPayload, ): void { - if (!this.service.initialized) { + const state = this.panelState(); + if (!state) { return; } this.sendMessage({ command: "result", action, result, - state: this.service.getStateForPanel(), + state, } as Extract); } @@ -459,12 +481,13 @@ export class LessonPanelManager { source?: TelemetrySource, ): Promise { const { result } = await this.service.checkSolution(source); - this.sendMessage({ - command: "result", - action: "check", - result, - state: this.service.getStateForPanel(), - }); + // Checking is async, so the panel may have gone away while it ran (a + // course switch disposes it). The result still goes back to the caller — + // there's just no webview left to render it. + const state = this.panelState(); + if (state) { + this.sendMessage({ command: "result", action: "check", result, state }); + } return result.passed; } } diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 20a0d65832d..37d8bd0aa6b 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -272,21 +272,6 @@ export class LearningService { }; } - /** - * State snapshot for the lesson webview panel. - * - * python-notebook courses don't use the panel at all — the notebook is the - * primary surface there — so calling this for one is a programming error. - */ - getStateForPanel(): LearningState { - if (this.activeCourse.kind === "python-notebook") { - throw new Error( - "The lesson panel is not used for python-notebook courses.", - ); - } - return this.getState(); - } - async next(source: TelemetrySource): Promise { const ws = this.requireWorkspace(); const currentPos = ws.progressData.position; From 6d96973782cfdfaef24f507643e4c168bc579c81 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 15:59:22 -0700 Subject: [PATCH 085/101] Fix typo --- source/vscode/src/learning/dropInCourseProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index 7411c046583..c267ccbcc1e 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -261,7 +261,7 @@ export class DropInCourseProvider implements CourseProvider { default: notebookEntry = notebookEntries[0]; log.warn( - `Unit "${unit.id}" has no multiple .ipynb notebooks in ${unitDir.fsPath} - using ${notebookEntry.name}.`, + `Unit "${unit.id}" has multiple .ipynb notebooks in ${unitDir.fsPath} - using ${notebookEntry.name}.`, ); break; } From 6e9f7c4abec1a80c32825e7371291a4f03adae32 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 15:59:34 -0700 Subject: [PATCH 086/101] Remove dead comment --- source/vscode/src/learning/commands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index a940fefd7f0..41c51d36428 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -299,7 +299,6 @@ async function openCourseNotebook( } const cellId = service.getCurrentExerciseCellId(); - // Fallback: open without pre-selecting a kernel. await vscode.commands.executeCommand( "vscode.openWith", notebookUri, From 99052a3682c0d1779a0e4269d39eaa88d6ddcc34 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 16:04:13 -0700 Subject: [PATCH 087/101] Add todo --- source/vscode/src/learning/service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 37d8bd0aa6b..9a80a3b1149 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -787,6 +787,8 @@ export class LearningService { course = await ws.registry.loadCourse(courseId); ws.courses.set(course.id, course); await this.scaffoldCourse(ws, course); + // TODO (acasey): if scaffolding fails, you basically have to reload the window. + // That's probably fine, but confirm. } if (course.kind === "python-notebook") { // Need to await extension installation since environment setup depends From adf58bee780e063c9afac66d1932436868b063b5 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 16:09:21 -0700 Subject: [PATCH 088/101] Fix initialization race --- source/vscode/src/learning/service.ts | 85 +++++++++++++++++++++------ 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 9a80a3b1149..6f9535cc103 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -59,6 +59,15 @@ import type { } from "./types.js"; import type { EnvironmentCheckStatus } from "./types.js"; +/** + * How many times {@link LearningService.tryInitialize} will re-evaluate after + * waiting on an in-flight attempt that couldn't satisfy it. + * + * Bounded so that a steady stream of detect-only probes can't keep a caller + * that needs creation looping forever. + */ +const MAX_INIT_ATTEMPTS = 3; + /** Build an {@link EnvironmentCheckItem}. */ function check( id: string, @@ -148,6 +157,8 @@ export class LearningService { private _progressFileWatcher: vscode.FileSystemWatcher | undefined; private _writingProgress = false; private _initPromise: Promise | undefined; + /** Whether {@link _initPromise} was started with `createIfMissing`. */ + private _initCreates = false; private readonly _disposables: vscode.Disposable[] = []; private _environment: EnvironmentManager | undefined; @@ -192,34 +203,74 @@ export class LearningService { * open folder instead of returning `false`. * * Safe to call multiple times — concurrent calls are coalesced and - * subsequent calls after success return immediately. + * subsequent calls after success return immediately. Gives up after + * {@link MAX_INIT_ATTEMPTS} rounds of waiting on other callers' attempts. */ async tryInitialize(options?: { createIfMissing?: boolean; }): Promise { - if (this.workspace) { - return true; - } + const create = options?.createIfMissing === true; - // If there's an in-flight attempt, wait for it first. - if (this._initPromise) { - const result = await this._initPromise; - // If init succeeded, or the caller doesn't need creation, we're done. - if (result || !options?.createIfMissing) { - return result; - } + for (let attempt = 1; attempt <= MAX_INIT_ATTEMPTS; attempt++) { if (this.workspace) { return true; } - // The in-flight attempt didn't create — fall through to retry. - // TODO (acasey): this retry isn't safe if A wins the initial race, leaving B and C waiting, - // and then fails to actually initialize, B and C will race to call detectAndLoadWorkspace. + + const inFlight = this._initPromise; + if (!inFlight) { + const succeeded = await this.startInitialize(create); + if (!succeeded && create) { + log.warn( + "Unable to create a QDK Learning workspace: no workspace folder is open.", + ); + } + return succeeded; + } + + // Joining an in-flight attempt is only sound when that attempt is at + // least as capable as what this caller needs. A detect-only attempt + // can't satisfy a caller that asked for creation. Whoever started the + // attempt reports its failure, so don't warn again here. + if (this._initCreates || !create) { + return await inFlight; + } + + // Let the weaker attempt finish rather than starting a second one + // alongside it, which would scaffold the same files twice. Then loop: + // by that point it may have found a workspace, or another caller may + // have started a creating attempt worth joining. Re-evaluating is what + // keeps concurrent callers from each launching their own attempt. + await inFlight.catch(() => false); + log.warn( + `QDK Learning workspace initialization attempt ${attempt} of ` + + `${MAX_INIT_ATTEMPTS} did not produce a workspace; retrying.`, + ); } - this._initPromise = this.detectAndLoadWorkspace(options).finally(() => { - this._initPromise = undefined; + log.warn( + `Giving up on initializing a QDK Learning workspace after ` + + `${MAX_INIT_ATTEMPTS} attempts.`, + ); + return false; + } + + /** + * Begin the one and only in-flight initialization attempt, publishing it + * so concurrent callers coalesce onto it instead of starting their own. + */ + private startInitialize(create: boolean): Promise { + const attempt = this.detectAndLoadWorkspace({ + createIfMissing: create, + }).finally(() => { + // Only retract our own attempt: a later one may already have replaced it. + if (this._initPromise === attempt) { + this._initPromise = undefined; + this._initCreates = false; + } }); - return await this._initPromise; + this._initPromise = attempt; + this._initCreates = create; + return attempt; } dispose(): void { From e6bf1aa6569ac7d28295afd8c43096dec5de069d Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 17:31:56 -0700 Subject: [PATCH 089/101] Tidy up duplicate ProgressData --- source/vscode/src/learning/service.ts | 142 +++++++++++++++----------- 1 file changed, 81 insertions(+), 61 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 6f9535cc103..4df62d05909 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1420,9 +1420,6 @@ export class LearningService { } } - const defaultCourse = - courses.get(KATAS_COURSE_ID) ?? courses.values().next().value; - // Build workspace state; assigned to this.workspace only after all // async setup succeeds so that `initialized` stays false on failure. const ws: WorkspaceState = { @@ -1431,16 +1428,7 @@ export class LearningService { learningFile, courses, registry, - progressData: { - version: 1, - position: { - courseId: defaultCourse?.id ?? "", - unitId: defaultCourse?.units[0]?.id ?? "", - activityId: defaultCourse?.units[0]?.activities[0]?.id ?? "", - }, - completions: {}, - startedAt: new Date().toISOString(), - }, + progressData: this.defaultProgressData(courses), }; await this.loadProgress(ws); @@ -1785,54 +1773,91 @@ export class LearningService { ); } + /** + * Overlay the saved `qdk-learning.json` onto `ws.progressData`. + * + * A missing, unreadable, or malformed file leaves `ws.progressData` + * untouched, so the caller's seed value stands. On a fresh workspace that + * seed is {@link defaultProgressData}; on {@link reloadProgress} it is the + * progress already in memory, which is preferable to discarding it because + * the file happens to be mid-write or corrupt. + */ private async loadProgress(ws: WorkspaceState): Promise { + let parsed; try { const bytes = await vscode.workspace.fs.readFile(ws.learningFile); - const parsed = JSON.parse(new TextDecoder().decode(bytes)); - if ( - parsed && - typeof parsed === "object" && - parsed.version === 1 && - typeof parsed.completions === "object" && - parsed.completions !== null && - typeof parsed.position === "object" && - parsed.position !== null - ) { - ws.progressData = parsed as ProgressFileData; - // Resolve the course the saved position points at, falling back to - // the default loaded course if it references one not yet loaded. - const course = - ws.courses.get(ws.progressData.position.courseId) ?? - this.defaultCourseOf(ws); - // Validate saved position references a known unit and activity - if (course && course.units.length > 0) { - const unit = - ws.progressData.position.courseId === course.id - ? course.units.find( - (k) => k.id === ws.progressData.position.unitId, - ) - : undefined; - const activityValid = - unit && - unit.activities.some( - (s) => s.id === ws.progressData.position.activityId, - ); - if (!activityValid) { - ws.progressData.position = { - courseId: course.id, - unitId: course.units[0].id, - activityId: course.units[0].activities[0]?.id ?? "", - }; - } - } - return; - } + parsed = JSON.parse(new TextDecoder().decode(bytes)); } catch { - // expected when file is missing or corrupt + // Expected when the file is missing or unparseable. + // In this case, leave ws (and, in particular, ws.ProgressData) + // untouched, since it's either the last known state or the default. + return; + } + + if ( + !parsed || + typeof parsed !== "object" || + parsed.version !== 1 || + typeof parsed.completions !== "object" || + parsed.completions === null || + typeof parsed.position !== "object" || + parsed.position === null + ) { + // As above, leave ws.ProgressData untouched if we can't load it + return; } - const course = this.defaultCourseOf(ws); - // TODO (acasey): is this identical to what was passed in? - ws.progressData = { + + ws.progressData = parsed as ProgressFileData; + // Resolve the course the saved position points at, falling back to + // the default loaded course if it references one not yet loaded. + const course = + ws.courses.get(ws.progressData.position.courseId) ?? + this.defaultCourse(ws.courses); + // Validate saved position references a known unit and activity + if (course && course.units.length > 0) { + const unit = + ws.progressData.position.courseId === course.id + ? course.units.find((k) => k.id === ws.progressData.position.unitId) + : undefined; + const activityValid = + unit && + unit.activities.some( + (s) => s.id === ws.progressData.position.activityId, + ); + if (!activityValid) { + // Keep the learner as close to where they left off as the catalog + // still allows: stay in their unit when it survived and only the + // activity is gone, and fall back to the start of the course only + // when the unit itself is no longer there. + const fallbackUnit = unit ?? course.units[0]; + ws.progressData.position = { + courseId: course.id, + unitId: fallbackUnit.id, + activityId: fallbackUnit.activities[0]?.id ?? "", + }; + } + } + } + + /** The default course for a workspace (built-in katas, else the first loaded). */ + private defaultCourse( + courses: Map, + ): CatalogCourse | undefined { + return courses.get(KATAS_COURSE_ID) ?? courses.values().next().value; + } + + /** + * A fresh progress file, positioned at the start of the default course. + * + * This is the single definition of "no progress yet". {@link WorkspaceState} + * is seeded with it, so {@link loadProgress} only has to handle the case + * where a saved file *is* readable. + */ + private defaultProgressData( + courses: Map, + ): ProgressFileData { + const course = this.defaultCourse(courses); + return { version: 1, position: { courseId: course?.id ?? "", @@ -1844,11 +1869,6 @@ export class LearningService { }; } - /** The default course for a workspace (built-in katas, else the first loaded). */ - private defaultCourseOf(ws: WorkspaceState): CatalogCourse | undefined { - return ws.courses.get(KATAS_COURSE_ID) ?? ws.courses.values().next().value; - } - private async saveProgress(): Promise { const ws = this.requireWorkspace(); const json = JSON.stringify(ws.progressData, null, 2); From 5f50a2ea8175436e8d3bec51d17e901392274cd0 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 17:34:50 -0700 Subject: [PATCH 090/101] Drop continue node in notebook courses --- source/vscode/src/learning/progressTreeView.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts index c1713b64640..cc520912644 100644 --- a/source/vscode/src/learning/progressTreeView.ts +++ b/source/vscode/src/learning/progressTreeView.ts @@ -100,7 +100,6 @@ class LearningProgressTreeProvider implements vscode.TreeDataProvider u.id === unitId); const activity = unit?.activities.find((a) => a.id === activityId); From ddc394c2565d12abebca3e69e537c172b89a3964 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 17:46:33 -0700 Subject: [PATCH 091/101] Remove course icon color --- source/vscode/src/learning/progressTreeView.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts index cc520912644..547d0afbc66 100644 --- a/source/vscode/src/learning/progressTreeView.ts +++ b/source/vscode/src/learning/progressTreeView.ts @@ -344,10 +344,7 @@ export type LearningProgressNode = // ─── Tree node icons ─── const iconCourse = new vscode.ThemeIcon("mortar-board"); -const iconPython = new vscode.ThemeIcon( - "notebook", - new vscode.ThemeColor("charts.blue"), -); +const iconPython = new vscode.ThemeIcon("notebook"); const iconContinue = new vscode.ThemeIcon( "sparkle", new vscode.ThemeColor("charts.blue"), From 1a843a56ffb22d7d754f7da0c7522ba6cc8ba380 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 17:53:27 -0700 Subject: [PATCH 092/101] Handle or expand easy TODOs --- source/vscode/src/learning/commands.ts | 10 +++++++--- source/vscode/src/learning/courseProvider.ts | 7 +++++-- source/vscode/src/learning/dropInCourseProvider.ts | 2 +- source/vscode/src/learning/service.ts | 8 ++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 41c51d36428..f6ff9f027c4 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -157,7 +157,10 @@ export function registerLearningCommands( // otherwise pick up where the learner left off. if (service.getActiveCourseInfo().kind === "python-notebook") { if (service.getProgress().stats.completedActivities === 0) { - // TODO (acasey): close this once a notebook is open + // TODO (acasey): the readme serves as a sort of splash screen while things are set up. + // Ideally, we would close it once you navigate away. + // Alternatively, we could go back to using a panel, which would have the advantage of + // being able to include a "Get Started" button (even greyed out while not ready?). await showCourseInfo(service, courseId); } else { await openCourseNotebook(service); @@ -493,7 +496,8 @@ async function runEnvironmentCheckCommand( service: LearningService, node?: LearningProgressNode, ): Promise { - // TODO (acasey): don't allow overlapping runs + // TODO (acasey): don't allow overlapping runs. + // I think the user can click the button while it's already running from switch-course. if (!service.initialized) { const ok = await service.tryInitialize({ createIfMissing: true }); if (!ok) { @@ -541,7 +545,7 @@ async function runEnvironmentCheckCommand( ].join("\n"); const actions = report.fixes.map((r) => r.label); - // TODO (acasey): this is ugly and unthemed - can we do better? + // TODO (acasey): this dialog is ugly and unthemed - can we do better? const choice = await vscode.window.showInformationMessage( body, { modal: true }, diff --git a/source/vscode/src/learning/courseProvider.ts b/source/vscode/src/learning/courseProvider.ts index eaffee4833d..0132cf0758b 100644 --- a/source/vscode/src/learning/courseProvider.ts +++ b/source/vscode/src/learning/courseProvider.ts @@ -2,6 +2,8 @@ // Licensed under the MIT License. // TODO (acasey): consider merging into catalog.ts +// A course catalog, a course registry, and a course provider seem +// semantically very similar. We might not need three distinct types. import { loadKatasCourse } from "./catalog.js"; import { KATAS_COURSE_ID } from "./constants.js"; @@ -44,7 +46,9 @@ export class CourseRegistry { continue; } for (const descriptor of descriptors) { - // TODO (acasey): what is this guarding against? Multiple providers offering the same course? One provider offering multiple courses with the same ID? + // TODO (acasey): what is this guarding against? + // Multiple providers offering the same course? + // One provider offering multiple courses with the same ID? if (seen.has(descriptor.id)) { continue; } @@ -76,7 +80,6 @@ export class CourseRegistry { } } -// TODO (acasey): separate file (if it survives) /** Provider for the built-in Quantum Katas course. */ export class KatasProvider implements CourseProvider { readonly id = "katas-provider"; diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index c267ccbcc1e..b8203b5a534 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -368,7 +368,7 @@ async function readDirSafe( async function tryReadText(uri: vscode.Uri): Promise { try { const bytes = await vscode.workspace.fs.readFile(uri); - return new TextDecoder().decode(bytes); // TODO (acasey): encoding? + return new TextDecoder().decode(bytes); } catch { return undefined; } diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 4df62d05909..3c64df4c779 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -837,7 +837,7 @@ export class LearningService { if (!course) { course = await ws.registry.loadCourse(courseId); ws.courses.set(course.id, course); - await this.scaffoldCourse(ws, course); + await this.materializeCourse(ws, course); // TODO (acasey): if scaffolding fails, you basically have to reload the window. // That's probably fine, but confirm. } @@ -1440,10 +1440,10 @@ export class LearningService { for (const course of courses.values()) { try { - await this.scaffoldCourse(ws, course); + await this.materializeCourse(ws, course); } catch { // A failing scaffold should not block workspace initialization. - // TODO (acasey): log + log.warn(`Failed to materialize course ${course.title}`); } } } @@ -1988,7 +1988,7 @@ export class LearningService { * for a Q# course into the learning content folder. No-op for non-qsharp * courses (those are scaffolded by their own runtime). */ - private async scaffoldCourse( + private async materializeCourse( ws: WorkspaceState, course: CatalogCourse, ): Promise { From 46392d6ef6bf93107a34fa868da06ebb24b1a486 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 18:01:07 -0700 Subject: [PATCH 093/101] Change getExerciseCellIds to isExerciseCellId --- source/vscode/src/learning/commands.ts | 2 +- .../src/learning/notebookCellStatusBar.ts | 3 +-- source/vscode/src/learning/service.ts | 19 ++++++------------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index f6ff9f027c4..4c382a652d2 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -253,7 +253,7 @@ export function registerLearningCommands( // The button is offered on every cell, so the cell may not be an // exercise. Only move the service's position when it is one. const cellId = resolveCellId(arg); - if (cellId && service.getExerciseCellIds().has(cellId)) { + if (cellId && service.isExerciseCellId(cellId)) { await service.goToExerciseByCellId(cellId, "notebook"); } diff --git a/source/vscode/src/learning/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts index aa0c97bd644..b45c8fa48b6 100644 --- a/source/vscode/src/learning/notebookCellStatusBar.ts +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -64,8 +64,7 @@ class LearningCellStatusBarProvider } // Only show the hint button for cells that are exercises. - const exerciseCellIds = service.getExerciseCellIds(); - if (!exerciseCellIds.has(cellId)) { + if (!service.isExerciseCellId(cellId)) { return []; } diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 3c64df4c779..289cf73e929 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -553,24 +553,17 @@ export class LearningService { }; } - // TODO (acasey): isExerciseCellId /** - * Returns the set of cell IDs that correspond to exercises in the - * current unit. Empty if the course isn't a python-notebook course or - * there are no exercises. + * Returns `true` if the given cell ID corresponds to an exercise in the + * current unit. Always `false` if the course isn't a python-notebook + * course or there are no exercises. */ - getExerciseCellIds(): Set { + isExerciseCellId(cellId: string): boolean { if (this.activeCourse.kind !== "python-notebook") { - return new Set(); + return false; } const unit = this.findUnit(this.position.unitId); - const ids = new Set(); - if (unit.notebookExercises) { - for (const ex of unit.notebookExercises) { - ids.add(ex.cellId); - } - } - return ids; + return unit.notebookExercises?.some((ex) => ex.cellId === cellId) ?? false; } /** From da8ba60a223e8873f32f6e885f97fc84a5371bb4 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 28 Jul 2026 18:10:25 -0700 Subject: [PATCH 094/101] Extract ipynb parser --- .../vscode/src/learning/notebookExercises.ts | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts index 0ffa29dd1a9..154cfc27f6b 100644 --- a/source/vscode/src/learning/notebookExercises.ts +++ b/source/vscode/src/learning/notebookExercises.ts @@ -53,6 +53,9 @@ interface RawNotebook { cells?: unknown; } +/** A {@link RawNotebook} whose `cells` array has been validated to exist. */ +type ParsedNotebook = RawNotebook & { cells: RawCell[] }; + /** * Parse the exercise metadata out of an authored notebook's JSON text. * @@ -175,7 +178,6 @@ export function parseNotebookExercises( return exercises; } -// TODO (acasey): share code with readCells /** * Remove the author-only cells from a notebook's JSON text, returning the * notebook the learner works in. @@ -189,23 +191,12 @@ export function stripAuthoringCells( text: string, unitLabel: string, ): string | undefined { - let notebook: RawNotebook; - try { - notebook = JSON.parse(text) as RawNotebook; - } catch (e) { - log.warn( - `Learning: failed to parse the notebook for unit "${unitLabel}": ${String(e)}`, - ); - return undefined; - } - if (!Array.isArray(notebook.cells)) { - log.warn( - `Learning: the notebook for unit "${unitLabel}" has no "cells" array.`, - ); + const notebook = parseNotebook(text, unitLabel); + if (!notebook) { return undefined; } - notebook.cells = (notebook.cells as RawCell[]).filter((cell) => { + notebook.cells = notebook.cells.filter((cell) => { const tags = cellTags(cell); return !AUTHORING_TAGS.some((t) => tags.includes(t)); }); @@ -217,7 +208,14 @@ export function stripAuthoringCells( // ─── Cell readers ─── -function readCells(text: string, unitLabel: string): RawCell[] | undefined { +/** + * Parse a notebook's JSON text and validate it has a `cells` array. Shared by + * every entry point that needs the raw notebook rather than just its cells. + */ +function parseNotebook( + text: string, + unitLabel: string, +): ParsedNotebook | undefined { let notebook: RawNotebook; try { notebook = JSON.parse(text) as RawNotebook; @@ -233,9 +231,14 @@ function readCells(text: string, unitLabel: string): RawCell[] | undefined { ); return undefined; } - return (notebook.cells as unknown[]).filter( + notebook.cells = (notebook.cells as unknown[]).filter( (c): c is RawCell => !!c && typeof c === "object", ); + return notebook as ParsedNotebook; +} + +function readCells(text: string, unitLabel: string): RawCell[] | undefined { + return parseNotebook(text, unitLabel)?.cells; } function cellTags(cell: RawCell): string[] { From 99db37080ad7e64015ec24cab5cf15bb323d015a Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 29 Jul 2026 11:44:24 -0700 Subject: [PATCH 095/101] First cut at rationalizing catalogs and course providers --- source/vscode/src/learning/commands.ts | 4 +- source/vscode/src/learning/courseLayout.ts | 65 ++++++++ source/vscode/src/learning/courseProvider.ts | 139 +++++++++--------- .../src/learning/dropInCourseProvider.ts | 78 ++++------ .../learning/{catalog.ts => katasProvider.ts} | 48 +++--- source/vscode/src/learning/notebookSync.ts | 2 +- .../vscode/src/learning/progressTreeView.ts | 4 +- .../src/learning/python/materialization.ts | 66 ++------- source/vscode/src/learning/service.ts | 110 ++++++-------- source/vscode/src/learning/types.d.ts | 20 ++- 10 files changed, 256 insertions(+), 280 deletions(-) create mode 100644 source/vscode/src/learning/courseLayout.ts rename source/vscode/src/learning/{catalog.ts => katasProvider.ts} (73%) diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 4c382a652d2..d56f864ac8a 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -446,7 +446,7 @@ async function resolveCourseId( return undefined; } } - const courses = await service.getCourses(); + const courses = service.getCourses(); if (courses.length === 0) { return undefined; } @@ -468,7 +468,7 @@ async function showCourseInfo( service: LearningService, courseId: string, ): Promise { - const courses = await service.getCourses(); + const courses = service.getCourses(); const descriptor = courses.find((c) => c.id === courseId); if (!descriptor) { log.warn(`Unable to show course info for unknown course ${courseId}`); diff --git a/source/vscode/src/learning/courseLayout.ts b/source/vscode/src/learning/courseLayout.ts new file mode 100644 index 00000000000..a45793ca3c2 --- /dev/null +++ b/source/vscode/src/learning/courseLayout.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { WORKBOOK_SUFFIX } from "./constants.js"; +import type { CatalogCourse, CatalogUnit } from "./types.js"; + +// Where a course's files live on disk. `sourceNotebookUri` is authored +// content that ships with the course; `workbookUri` is the learner's +// editable copy, which exists only once the course has been materialized. + +/** Root folder a course was loaded from. Drop-in courses only. */ +export function courseRootUri(course: CatalogCourse): vscode.Uri { + if (!course.sourceDir) { + throw new Error(`Course "${course.id}" has no source folder.`); + } + return vscode.Uri.parse(course.sourceDir); +} + +/** The units of a course that have an authored notebook. */ +export function notebookUnits(course: CatalogCourse): CatalogUnit[] { + return course.units.filter((u) => u.sourceNotebookRel !== undefined); +} + +/** The authored notebook that a unit's workbook is derived from. */ +export function sourceNotebookUri( + course: CatalogCourse, + unit: CatalogUnit, +): vscode.Uri { + return vscode.Uri.joinPath( + courseRootUri(course), + requireSourceNotebookRel(course, unit), + ); +} + +/** + * The learner's editable copy of a unit's notebook: a `*.workbook.ipynb` + * file beside the authored source. + * + * Keeping it as a sibling means the learner's notebook resolves the same + * relative imports (`_course_lib.py`, `_unit.py`) as the source. Returns a + * URI whether or not the file exists yet. + */ +export function workbookUri( + course: CatalogCourse, + unit: CatalogUnit, +): vscode.Uri { + const rel = requireSourceNotebookRel(course, unit); + return vscode.Uri.joinPath( + courseRootUri(course), + rel.replace(/\.ipynb$/i, WORKBOOK_SUFFIX), + ); +} + +function requireSourceNotebookRel( + course: CatalogCourse, + unit: CatalogUnit, +): string { + if (!unit.sourceNotebookRel) { + throw new Error( + `Unit "${unit.id}" in course "${course.id}" has no notebook.`, + ); + } + return unit.sourceNotebookRel; +} diff --git a/source/vscode/src/learning/courseProvider.ts b/source/vscode/src/learning/courseProvider.ts index 0132cf0758b..7765dc0c73f 100644 --- a/source/vscode/src/learning/courseProvider.ts +++ b/source/vscode/src/learning/courseProvider.ts @@ -1,105 +1,98 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// TODO (acasey): consider merging into catalog.ts -// A course catalog, a course registry, and a course provider seem -// semantically very similar. We might not need three distinct types. - -import { loadKatasCourse } from "./catalog.js"; -import { KATAS_COURSE_ID } from "./constants.js"; +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import { DropInCourseProvider } from "./dropInCourseProvider.js"; +import { KatasProvider } from "./katasProvider.js"; import type { CatalogCourse, CourseDescriptor } from "./types.js"; /** - * A source of learning courses. Implementations know how to enumerate the - * courses they provide and how to fully load a course by id. + * A source of learning courses. Implementations know how to find the courses + * they provide and parse them into memory. * - * Loading is intentionally split from enumeration so the UI can list - * available courses cheaply without materializing every course. + * Loading a course only reads and parses it. Creating the learner's editable + * files is a separate step — see `materializeCourseWorkbooks`. */ export interface CourseProvider { /** Stable identifier for this provider (for diagnostics/telemetry). */ readonly id: string; - /** Enumerate the descriptors for all courses this provider offers. */ - listCourses(): Promise; - /** Fully load a course by id. Returns `undefined` if not provided here. */ - loadCourse(id: string): Promise; + /** Find and parse every course this provider offers. */ + listCourses(): Promise; } /** - * Aggregates multiple {@link CourseProvider}s into a single catalog of - * courses. The registry is the single entry point the service uses to - * discover and load courses regardless of where they come from. + * Aggregates multiple {@link CourseProvider}s so the service has a single + * place to ask for courses regardless of where they come from. */ -export class CourseRegistry { +export class CompositeCourseProvider implements CourseProvider { + readonly id = "composite-provider"; + constructor(private readonly providers: CourseProvider[]) {} - /** Enumerate descriptors across all providers, in provider order. */ - async listCourses(): Promise { - const all: CourseDescriptor[] = []; - const seen = new Set(); + /** + * Parse the courses from every provider, in provider order. When two + * providers offer the same course id, the earlier provider wins. + */ + async listCourses(): Promise { + const all: CatalogCourse[] = []; + // Course id -> id of the provider that claimed it. + const claimedBy = new Map(); + for (const provider of this.providers) { - let descriptors: CourseDescriptor[]; + let courses: CatalogCourse[]; try { - descriptors = await provider.listCourses(); - } catch { + courses = await provider.listCourses(); + } catch (e) { // A misbehaving provider should not break the whole catalog. + log.warn( + `Course provider "${provider.id}" failed to list courses: ${String(e)}`, + ); continue; } - for (const descriptor of descriptors) { - // TODO (acasey): what is this guarding against? - // Multiple providers offering the same course? - // One provider offering multiple courses with the same ID? - if (seen.has(descriptor.id)) { + + for (const course of courses) { + const winner = claimedBy.get(course.id); + if (winner !== undefined) { + const from = course.sourceDir + ? ` at ${vscode.Uri.parse(course.sourceDir).fsPath}` + : ""; + log.warn( + `Ignoring course "${course.id}" from "${provider.id}"${from}: ` + + `that id is already provided by "${winner}".`, + ); continue; } - seen.add(descriptor.id); - all.push(descriptor); + claimedBy.set(course.id, provider.id); + all.push(course); } } return all; } - - /** Look up a single descriptor by id, or `undefined` if not found. */ - async getDescriptor(id: string): Promise { - const all = await this.listCourses(); - return all.find((d) => d.id === id); - } - - /** - * Fully load a course by id. Tries each provider in order and returns - * the first match. Throws if no provider can load the course. - */ - async loadCourse(id: string): Promise { - for (const provider of this.providers) { - const course = await provider.loadCourse(id); - if (course) { - return course; - } - } - throw new Error(`No provider could load course "${id}".`); - } } -/** Provider for the built-in Quantum Katas course. */ -export class KatasProvider implements CourseProvider { - readonly id = "katas-provider"; - - async listCourses(): Promise { - return [ - { - id: KATAS_COURSE_ID, - title: "Quantum Katas", - shortDescription: - "Hands-on quantum computing tutorials and exercises in Q#.", - kind: "qsharp", - }, - ]; - } +/** + * Create the {@link CompositeCourseProvider} with every available source of + * courses: the built-in Quantum Katas plus any courses authored on disk + * under `qdk-learning/courses/*`. + */ +export function createCourseProvider( + workspaceRoot: vscode.Uri, +): CompositeCourseProvider { + return new CompositeCourseProvider([ + new KatasProvider(), + new DropInCourseProvider(workspaceRoot), + ]); +} - async loadCourse(id: string): Promise { - if (id !== KATAS_COURSE_ID) { - return undefined; - } - return await loadKatasCourse(); - } +/** Project a loaded course down to the summary used by UI surfaces. */ +export function toDescriptor(course: CatalogCourse): CourseDescriptor { + return { + id: course.id, + title: course.title, + shortDescription: course.shortDescription, + kind: course.kind, + readmePath: course.readmePath, + environment: course.environment, + }; } diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index b8203b5a534..a78d90d9194 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// TODO (acasey): consider merging into catalog.ts - import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { @@ -20,7 +18,6 @@ import type { CatalogCourse, CatalogExercise, CatalogUnit, - CourseDescriptor, CourseEnvironment, NotebookExerciseInfo, } from "./types.js"; @@ -66,34 +63,24 @@ export class DropInCourseProvider implements CourseProvider { constructor(private readonly workspaceRoot: vscode.Uri) {} - async listCourses(): Promise { - const locations = await this.discover(); + async listCourses(): Promise { + const courses: CatalogCourse[] = []; const seen = new Set(); - const descriptors: CourseDescriptor[] = []; - for (const loc of locations) { - const descriptor = await this.toDescriptor(loc); - if (!descriptor || seen.has(descriptor.id)) { - if (descriptor && seen.has(descriptor.id)) { - log.warn( - `Duplicate drop-in course id "${descriptor.id}" ignored at ${loc.dir.toString()}`, - ); - } + for (const loc of await this.discover()) { + const course = await this.parseCourse(loc); + if (!course) { continue; } - seen.add(descriptor.id); - descriptors.push(descriptor); - } - return descriptors; - } - - async loadCourse(id: string): Promise { - const locations = await this.discover(); - for (const loc of locations) { - if (manifestString(loc.manifest.id) === id) { - return this.parseCourse(loc); + if (seen.has(course.id)) { + log.warn( + `Duplicate drop-in course id "${course.id}" ignored at ${loc.dir.toString()}`, + ); + continue; } + seen.add(course.id); + courses.push(course); } - return undefined; + return courses; } // ─── Discovery ─── @@ -153,28 +140,6 @@ export class DropInCourseProvider implements CourseProvider { // ─── Parsing ─── - private async toDescriptor( - loc: CourseLocation, - ): Promise { - const id = manifestString(loc.manifest.id); - const title = manifestString(loc.manifest.title); - if (id === undefined || title === undefined) { - return undefined; - } - const descriptor: CourseDescriptor = { - id, - title, - kind: "python-notebook", - shortDescription: manifestString(loc.manifest.shortDescription), - environment: manifestEnvironment(loc.manifest.environment), - }; - const readmeUri = vscode.Uri.joinPath(loc.dir, COURSE_README_FILE); - if (await uriExists(readmeUri)) { - descriptor.readmePath = readmeUri.toString(); - } - return descriptor; - } - private async parseCourse( loc: CourseLocation, ): Promise { @@ -193,7 +158,7 @@ export class DropInCourseProvider implements CourseProvider { ); continue; } - const { activities, notebookExercises, notebookRel } = + const { activities, notebookExercises, sourceNotebookRel } = await this.parseNotebookUnit(unitDir, manifestUnit); if (activities.length === 0) { log.warn( @@ -205,16 +170,23 @@ export class DropInCourseProvider implements CourseProvider { title: manifestUnit.title, activities, notebookExercises, - notebookRel, + sourceNotebookRel, }); } + const readmeUri = vscode.Uri.joinPath(loc.dir, COURSE_README_FILE); + const readmePath = (await uriExists(readmeUri)) + ? readmeUri.toString() + : undefined; + return { id, title, + shortDescription: manifestString(loc.manifest.shortDescription), kind: "python-notebook", units, sourceDir: loc.dir.toString(), + readmePath, environment: manifestEnvironment(loc.manifest.environment), }; } @@ -234,7 +206,7 @@ export class DropInCourseProvider implements CourseProvider { ): Promise<{ activities: CatalogActivity[]; notebookExercises?: NotebookExerciseInfo[]; - notebookRel?: string; + sourceNotebookRel?: string; }> { // Find the source notebook file in the unit dir. Materialized working // copies (`*.workbook.ipynb`) sit beside the source and must be ignored @@ -266,7 +238,7 @@ export class DropInCourseProvider implements CourseProvider { break; } - const notebookRel = `${unit.dir}/${notebookEntry.name}`; + const sourceNotebookRel = `${unit.dir}/${notebookEntry.name}`; const activities: CatalogActivity[] = []; @@ -297,7 +269,7 @@ export class DropInCourseProvider implements CourseProvider { } } - return { activities, notebookExercises, notebookRel }; + return { activities, notebookExercises, sourceNotebookRel }; } } diff --git a/source/vscode/src/learning/catalog.ts b/source/vscode/src/learning/katasProvider.ts similarity index 73% rename from source/vscode/src/learning/catalog.ts rename to source/vscode/src/learning/katasProvider.ts index e1638cbca14..23857817f2a 100644 --- a/source/vscode/src/learning/catalog.ts +++ b/source/vscode/src/learning/katasProvider.ts @@ -2,21 +2,26 @@ // Licensed under the MIT License. import { getAllKatas } from "qsharp-lang/katas-md"; -import * as vscode from "vscode"; import { KATAS_COURSE_ID } from "./constants.js"; -import { CourseRegistry, KatasProvider } from "./courseProvider.js"; -import { DropInCourseProvider } from "./dropInCourseProvider.js"; +import type { CourseProvider } from "./courseProvider.js"; import type { - CatalogUnit, - CatalogCourse, CatalogActivity, + CatalogCourse, CatalogExercise, + CatalogUnit, } from "./types.js"; -/** - * Load the built-in Quantum Katas as a single `CatalogCourse`. - */ -export async function loadKatasCourse(): Promise { +/** Provider for the built-in Quantum Katas course. */ +export class KatasProvider implements CourseProvider { + readonly id = "katas-provider"; + + async listCourses(): Promise { + return [await loadKatasCourse()]; + } +} + +/** Load the built-in Quantum Katas as a single {@link CatalogCourse}. */ +async function loadKatasCourse(): Promise { const raw = await getAllKatas(); const units: CatalogUnit[] = raw.map((kata) => ({ id: kata.id, @@ -83,21 +88,12 @@ export async function loadKatasCourse(): Promise { }), })); - return { id: KATAS_COURSE_ID, title: "Quantum Katas", kind: "qsharp", units }; -} - -/** - * Create the {@link CourseRegistry} with all available course providers. - * - * Registers the built-in Quantum Katas provider plus a - * {@link DropInCourseProvider} that discovers courses authored on disk - * (under `qdk-learning/courses/*`). - */ -export function createCourseRegistry( - workspaceRoot: vscode.Uri, -): CourseRegistry { - return new CourseRegistry([ - new KatasProvider(), - new DropInCourseProvider(workspaceRoot), - ]); + return { + id: KATAS_COURSE_ID, + title: "Quantum Katas", + shortDescription: + "Hands-on quantum computing tutorials and exercises in Q#.", + kind: "qsharp", + units, + }; } diff --git a/source/vscode/src/learning/notebookSync.ts b/source/vscode/src/learning/notebookSync.ts index 6f4cbf6b1ce..5aa3cade72a 100644 --- a/source/vscode/src/learning/notebookSync.ts +++ b/source/vscode/src/learning/notebookSync.ts @@ -41,7 +41,7 @@ async function syncActiveNotebook( // Detect-only — never `createIfMissing`. A `*.workbook.ipynb` is // generated during initialization, so its presence normally implies a // learning workspace already exists. When it doesn't, the learner - // hasn't started yet and merely opening a notebook must not scaffold + // hasn't started yet and merely opening a notebook must not materialize // one behind their back. if (await service.tryInitialize()) { await service.syncToWorkbook(editor.notebook.uri); diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts index 547d0afbc66..a62968818cb 100644 --- a/source/vscode/src/learning/progressTreeView.ts +++ b/source/vscode/src/learning/progressTreeView.ts @@ -181,7 +181,7 @@ class LearningProgressTreeProvider implements vscode.TreeDataProvider { - if (!course.sourceDir) { - throw new Error(`Course "${course.id}" has no source folder.`); - } - const sourceRoot = vscode.Uri.parse(course.sourceDir); - - for (const unit of course.units) { - if (!unit.notebookRel) { - continue; - } - const dest = vscode.Uri.joinPath( - sourceRoot, - toWorkbookRel(unit.notebookRel), - ); + for (const unit of notebookUnits(course)) { + const dest = workbookUri(course, unit); if (await uriExists(dest)) { continue; } - await materializeNotebook( - vscode.Uri.joinPath(sourceRoot, unit.notebookRel), - dest, - unit.id, - ); + await materializeNotebook(sourceNotebookUri(course, unit), dest, unit.id); } } @@ -67,18 +37,13 @@ export async function rematerializeUnitWorkbook( course: CatalogCourse, unitId: string, ): Promise { - if (!course.sourceDir) { - throw new Error(`Course "${course.id}" has no source folder.`); - } const unit = course.units.find((u) => u.id === unitId); - if (!unit?.notebookRel) { + if (!unit) { throw new Error(`Unit "${unitId}" not found in course "${course.id}".`); } - - const sourceRoot = vscode.Uri.parse(course.sourceDir); await materializeNotebook( - vscode.Uri.joinPath(sourceRoot, unit.notebookRel), - vscode.Uri.joinPath(sourceRoot, toWorkbookRel(unit.notebookRel)), + sourceNotebookUri(course, unit), + workbookUri(course, unit), unit.id, ); } @@ -116,12 +81,3 @@ async function materializeNotebook( ); } } - -/** - * Map a source notebook's relative path to its working-copy sibling by - * swapping the `.ipynb` extension for `.workbook.ipynb` - * (e.g. `01-intro/intro.ipynb` → `01-intro/intro.workbook.ipynb`). - */ -function toWorkbookRel(notebookRel: string): string { - return notebookRel.replace(/\.ipynb$/i, WORKBOOK_SUFFIX); -} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 289cf73e929..6bbcb7247d5 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -7,8 +7,8 @@ import * as vscode from "vscode"; import { FullProgramConfig, getProgramForDocument } from "../programConfig.js"; import { ProgramRunStatus, runProgram } from "../run.js"; import { EventType, sendTelemetryEvent } from "../telemetry.js"; -import { createCourseRegistry } from "./catalog.js"; -import { CourseRegistry } from "./courseProvider.js"; +import { createCourseProvider, toDescriptor } from "./courseProvider.js"; +import { courseRootUri, workbookUri } from "./courseLayout.js"; import { EnvironmentManager } from "./python/environment.js"; import { checkPythonExtensions, @@ -17,7 +17,6 @@ import { import { materializeCourseWorkbooks, rematerializeUnitWorkbook, - workbookFileUri, } from "./python/materialization.js"; import { KATAS_COURSE_ID, @@ -136,10 +135,8 @@ interface LearningWorkspaceInfo { /** All state that exists only while a learning workspace is loaded. */ interface WorkspaceState extends LearningWorkspaceInfo { - /** Loaded courses, keyed by course id. May contain more than one. */ + /** Every course found at load time, keyed by course id. */ courses: Map; - /** Registry used to enumerate and lazily load additional courses. */ - registry: CourseRegistry; progressData: ProgressFileData; } @@ -236,7 +233,7 @@ export class LearningService { } // Let the weaker attempt finish rather than starting a second one - // alongside it, which would scaffold the same files twice. Then loop: + // alongside it, which would materialize the same files twice. Then loop: // by that point it may have found a workspace, or another caller may // have started a creating attempt worth joining. Re-evaluating is what // keeps concurrent callers from each launching their own attempt. @@ -516,10 +513,10 @@ export class LearningService { continue; } for (const unit of course.units) { - if (!unit.notebookRel) { + if (!unit.sourceNotebookRel) { continue; } - const workbook = workbookFileUri(course, unit.notebookRel); + const workbook = workbookUri(course, unit); if (workbook.toString() === target) { return { course, unit }; } @@ -579,9 +576,9 @@ export class LearningService { return unit.notebookExercises?.find((e) => e.id === activity.id)?.cellId; } - /** Enumerate all available courses (loaded or not). */ - async getCourses(): Promise { - return this.requireWorkspace().registry.listCourses(); + /** Enumerate all available courses. */ + getCourses(): CourseDescriptor[] { + return [...this.requireWorkspace().courses.values()].map(toDescriptor); } /** The id of the currently-active course. */ @@ -616,7 +613,7 @@ export class LearningService { if (!course.sourceDir) { return; } - const courseRoot = vscode.Uri.parse(course.sourceDir); + const courseRoot = courseRootUri(course); if (!options?.force && (await env.environmentExists(courseRoot))) { return; } @@ -692,7 +689,7 @@ export class LearningService { }), ]); } - const courseRoot = vscode.Uri.parse(course.sourceDir); + const courseRoot = courseRootUri(course); const checks: EnvironmentCheckItem[] = []; @@ -817,23 +814,21 @@ export class LearningService { } /** - * Switch the active course. Lazily loads the course (and scaffolds its - * files) if it isn't loaded yet, moves the position to the first - * incomplete activity, persists, and fires change events. + * Switch the active course, creating its learner-editable files if they + * don't exist yet, then move the position to the first incomplete + * activity, persist, and fire change events. */ async switchCourse( courseId: string, source?: TelemetrySource, ): Promise { const ws = this.requireWorkspace(); - let course = ws.courses.get(courseId); - if (!course) { - course = await ws.registry.loadCourse(courseId); - ws.courses.set(course.id, course); - await this.materializeCourse(ws, course); - // TODO (acasey): if scaffolding fails, you basically have to reload the window. - // That's probably fine, but confirm. - } + const course = this.requireCourse(ws, courseId); + // Idempotent: existing workbooks are left alone, so this only writes + // files the first time the learner opens the course. + // TODO (acasey): if materializing fails, you basically have to reload the + // window. That's probably fine, but confirm. + await this.materializeCourse(ws, course); if (course.kind === "python-notebook") { // Need to await extension installation since environment setup depends // on the Python Environments extension @@ -916,18 +911,13 @@ export class LearningService { } /** - * Compute progress for an arbitrary course, lazily loading it if needed. - * Does **not** change the active course or position. Used to populate - * per-course progress badges in the tree view. + * Compute progress for an arbitrary course. Does **not** change the active + * course or position. Used to populate per-course progress badges in the + * tree view. */ - async getCourseProgress(courseId: string): Promise { + getCourseProgress(courseId: string): OverallProgress { const ws = this.requireWorkspace(); - let course = ws.courses.get(courseId); - if (!course) { - course = await ws.registry.loadCourse(courseId); - ws.courses.set(course.id, course); - } - return this.computeProgress(course); + return this.computeProgress(this.requireCourse(ws, courseId)); } private computeProgress(course: CatalogCourse): OverallProgress { @@ -1054,8 +1044,8 @@ export class LearningService { // Python-notebook courses: the "code" is the notebook itself. if (this.activeCourse.kind === "python-notebook") { const { unit } = this.findCurrentActivity(); - if (unit.notebookRel) { - return this.notebookFileUri(unit.notebookRel); + if (unit.sourceNotebookRel) { + return workbookUri(this.activeCourse, unit); } return undefined; } @@ -1079,9 +1069,8 @@ export class LearningService { if (this.activeCourse.kind === "python-notebook") { const { unit } = this.findCurrentActivity(); // Close any open notebook tabs for this unit. - if (unit.notebookRel) { - const notebookUri = this.notebookFileUri(unit.notebookRel); - await this.closeNotebookTab(notebookUri); + if (unit.sourceNotebookRel) { + await this.closeNotebookTab(workbookUri(this.activeCourse, unit)); } // Re-materialize the unit from source. await rematerializeUnitWorkbook(this.activeCourse, unit.id); @@ -1396,21 +1385,13 @@ export class LearningService { ): Promise { const learningFile = vscode.Uri.joinPath(workspaceRoot, LEARNING_FILE); - const registry = createCourseRegistry(workspaceRoot); + const courseProvider = createCourseProvider(workspaceRoot); - // Eagerly load all available courses so that the saved position - // (which may reference a drop-in course) resolves correctly. + // Load every course up front so the tree view can show unit counts and + // progress badges, and so a saved position naming any course resolves. const courses = new Map(); - const descriptors = await registry.listCourses(); - for (const descriptor of descriptors) { - try { - // TODO (acasey): parsing all courses seems fine, but we probably only want to materialize the active one - // TODO (acasey): this shouldn't redo discovery for each course - const course = await registry.loadCourse(descriptor.id); - courses.set(course.id, course); - } catch { - // Skip courses that fail to load. - } + for (const course of await courseProvider.listCourses()) { + courses.set(course.id, course); } // Build workspace state; assigned to this.workspace only after all @@ -1420,22 +1401,28 @@ export class LearningService { learningContentRoot: katasRoot, learningFile, courses, - registry, progressData: this.defaultProgressData(courses), }; await this.loadProgress(ws); - // Publish the workspace before scaffolding so that methods relying on + // Publish the workspace before materializing so that methods relying on // `requireWorkspace()` can resolve. this.workspace = ws; this.syncContextKey(); + // Q# files are cheap to write, so materialize those courses up front. + // Notebook workbooks are only created for the course the learner is on; + // the rest wait until `switchCourse`. + const activeCourseId = ws.progressData.position.courseId; for (const course of courses.values()) { + if (course.kind !== "qsharp" && course.id !== activeCourseId) { + continue; + } try { await this.materializeCourse(ws, course); } catch { - // A failing scaffold should not block workspace initialization. + // A failure here should not block workspace initialization. log.warn(`Failed to materialize course ${course.title}`); } } @@ -1657,11 +1644,6 @@ export class LearningService { } satisfies LessonTextContent; } - /** Working-copy (`*.workbook.ipynb`) URI of a notebook for the active python-notebook course. */ - private notebookFileUri(notebookRel: string): vscode.Uri { - return workbookFileUri(this.activeCourse, notebookRel); - } - private findCurrentActivity(): { unit: CatalogUnit; activity: CatalogActivity; @@ -1977,9 +1959,9 @@ export class LearningService { } /** - * Materialize the editable files (exercise placeholders and example code) - * for a Q# course into the learning content folder. No-op for non-qsharp - * courses (those are scaffolded by their own runtime). + * Create the learner-editable files for a course: workbooks for + * python-notebook courses, exercise placeholders and example code for Q# + * courses. Existing files are left alone, so this is safe to re-run. */ private async materializeCourse( ws: WorkspaceState, diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index 2ee1837745e..a07a519d006 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -295,15 +295,23 @@ export interface CatalogUnit { * Path (relative to the course source dir) of the notebook for this * unit. Set for python-notebook courses. */ - notebookRel?: string; + sourceNotebookRel?: string; } /** The execution model for a course's activities. */ export type CourseKind = "qsharp" | "python-notebook"; +/** + * The complete in-memory model of a loaded course: every unit, activity, + * hint and solution. Held by the service for the courses it knows about. + * + * Being loaded says nothing about whether the course's learner-editable + * files exist on disk; see `materializeCourseWorkbooks`. + */ export interface CatalogCourse { id: string; title: string; + shortDescription?: string; /** Execution model for this course. Defaults to `"qsharp"`. */ kind: CourseKind; units: CatalogUnit[]; @@ -312,14 +320,18 @@ export interface CatalogCourse { * only). Used to locate notebooks and other assets for materialization. */ sourceDir?: string; // TODO (acasey): vscode.Uri? + /** Optional path (URI string) to a README rendered for "Course info". */ + readmePath?: string; /** Environment requirements (python-notebook courses). */ environment?: CourseEnvironment; } /** - * Lightweight metadata describing a course that can be loaded by the - * {@link CourseRegistry}. Used to populate course pickers and the tree - * view without forcing a full course load. + * A flat summary of a course, used at UI and serialization boundaries — tree + * rows, the course quick pick, and chat tool payloads — where the unit + * contents are irrelevant and shouldn't be serialized. + * + * Derived from a {@link CatalogCourse} via `toDescriptor`. */ export interface CourseDescriptor { id: string; From aee7570045942902208e57bfd559ae2db33f116e Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 29 Jul 2026 12:54:56 -0700 Subject: [PATCH 096/101] Validate paths from course.json --- .../src/learning/dropInCourseProvider.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/source/vscode/src/learning/dropInCourseProvider.ts b/source/vscode/src/learning/dropInCourseProvider.ts index a78d90d9194..516b191211d 100644 --- a/source/vscode/src/learning/dropInCourseProvider.ts +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -319,11 +319,32 @@ function manifestUnits(value: unknown, dir: vscode.Uri): ManifestUnit[] { ); continue; } + if (!isContainedRelativePath(unitDir)) { + log.warn( + `Ignoring unit "${id}" in course at ${dir.toString()}: "dir" must be a relative path inside the course folder.`, + ); + continue; + } units.push({ id, title, dir: unitDir }); } return units; } +/** + * True when a manifest-supplied path stays inside the course folder. + * + * `dir` is the only path segment a course author controls, and it is joined + * onto the course root to locate notebooks that are later read and written. + * `Uri.joinPath` resolves `..`, so an unchecked value could escape the + * workspace entirely. + */ +function isContainedRelativePath(value: string): boolean { + if (/^[/\\]/.test(value) || /^[a-zA-Z]:/.test(value)) { + return false; + } + return !value.split(/[/\\]/).includes(".."); +} + // ─── Filesystem helpers ─── async function readDirSafe( From 3a1f6bfa2d7b0d39f577ee7e0afed1d754511fe8 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 29 Jul 2026 13:20:28 -0700 Subject: [PATCH 097/101] Comment about HTML injection --- source/vscode/src/learning/service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 6bbcb7247d5..20df266f775 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1595,6 +1595,9 @@ export class LearningService { if (activity.type === "exercise") { // Python-notebook exercises live in the notebook — show their // description as lesson text so the panel renders something useful. + // TODO (acasey): If we went back to using the lesson panel, we'd probably + // need to sanitize activity.description (course author-provided) before + // it gets rendered as HTML/markdown. if (this.activeCourse.kind === "python-notebook") { return { type: "lesson-text", From ea694f353db80f14bff4d50c194b0bf5510bd5ce Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 29 Jul 2026 13:33:27 -0700 Subject: [PATCH 098/101] Handle environment creation race --- .../vscode/src/learning/python/environment.ts | 22 ++++++++++++++++--- source/vscode/src/learning/service.ts | 6 ++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/source/vscode/src/learning/python/environment.ts b/source/vscode/src/learning/python/environment.ts index 7c4361d21ed..4737fc767e1 100644 --- a/source/vscode/src/learning/python/environment.ts +++ b/source/vscode/src/learning/python/environment.ts @@ -27,9 +27,12 @@ export class EnvironmentManager { string, PythonEnvironment >(); + /** In-flight {@link ensureEnvironment} calls, keyed by courseRoot.toString(). */ + private readonly _pendingEnvironments = new Map>(); dispose(): void { this._projectEnvironmentMap.clear(); + this._pendingEnvironments.clear(); } /** True on a host where environment management can run (desktop only). */ @@ -50,6 +53,20 @@ export class EnvironmentManager { if (!this.supported) { return; } + // Finding an existing environment and creating one are separate awaits, so + // concurrent callers must share a single attempt or each creates its own. + const key = courseRoot.toString(); + let pending = this._pendingEnvironments.get(key); + if (!pending) { + pending = this.resolveEnvironment(courseRoot).finally(() => { + this._pendingEnvironments.delete(key); + }); + this._pendingEnvironments.set(key, pending); + } + return pending; + } + + private async resolveEnvironment(courseRoot: vscode.Uri): Promise { const api = await this.pythonEnvironmentsApi(); if (!api) { log.warn( @@ -79,9 +96,6 @@ export class EnvironmentManager { return; } - // Cache the resolved environment. - this._projectEnvironmentMap.set(courseRoot.toString(), env); - // Register the course folder as a Python project. This creates a workspace // setting, which causes Jupyter to pick up the venv. const courseName = courseRoot.path.split("/").pop(); @@ -90,6 +104,8 @@ export class EnvironmentManager { uri: courseRoot, }); } + + this._projectEnvironmentMap.set(courseRoot.toString(), env); } /** diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 20df266f775..1ceb058ec20 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -833,7 +833,11 @@ export class LearningService { // Need to await extension installation since environment setup depends // on the Python Environments extension await promptInstallPythonExtensions(); - void this.ensureEnvironment(course); + this.ensureEnvironment(course).catch((e) => { + log.warn( + `Failed to set up the environment for "${course.title}": ${String(e)}`, + ); + }); } ws.progressData.position = this.firstIncompletePosition(course); await this.saveProgress(); From af0644baffbcd6b43becdeef5ce1a5e4896dc575 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 29 Jul 2026 13:36:38 -0700 Subject: [PATCH 099/101] Remove stale TODOs --- source/vscode/src/learning/python/extensionUtils.ts | 2 -- source/vscode/src/learning/types.d.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/source/vscode/src/learning/python/extensionUtils.ts b/source/vscode/src/learning/python/extensionUtils.ts index 89713cbe91f..9473a73be39 100644 --- a/source/vscode/src/learning/python/extensionUtils.ts +++ b/source/vscode/src/learning/python/extensionUtils.ts @@ -40,8 +40,6 @@ export function checkPythonExtensions(): string | undefined { .join(" and ")} extension${missing.length > 1 ? "s" : ""}.`; } -// TODO (acasey): there's no real reason to prompt here if it's only reachable from -// the environment check dialog and the user already clicked a button. /** * Prompt the user to install any missing required extensions. Safe to * call when nothing is missing (it no-ops). diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index a07a519d006..7c3de42de85 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -319,7 +319,7 @@ export interface CatalogCourse { * URI string of the folder the course was loaded from (drop-in courses * only). Used to locate notebooks and other assets for materialization. */ - sourceDir?: string; // TODO (acasey): vscode.Uri? + sourceDir?: string; /** Optional path (URI string) to a README rendered for "Course info". */ readmePath?: string; /** Environment requirements (python-notebook courses). */ From 693c01082344debab6bed886879e692b48a742a2 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 5 Aug 2026 09:57:51 -0700 Subject: [PATCH 100/101] Drop references to non-existent integration tests --- source/vscode/test/buildTests.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/vscode/test/buildTests.mjs b/source/vscode/test/buildTests.mjs index bdfb24d2e53..1d269586e21 100644 --- a/source/vscode/test/buildTests.mjs +++ b/source/vscode/test/buildTests.mjs @@ -27,7 +27,6 @@ const platformBuildOptions = { join(thisDir, "suites", "empty", "index.browser.ts"), join(thisDir, "suites", "language-service", "index.browser.ts"), join(thisDir, "suites", "debugger", "index.browser.ts"), - join(thisDir, "suites", "learning", "index.browser.ts"), ], platform: "browser", outdir: join(thisDir, "out", "browser"), @@ -38,7 +37,6 @@ const platformBuildOptions = { entryPoints: [ join(thisDir, "suites", "language-service", "index.node.ts"), join(thisDir, "suites", "debugger", "index.node.ts"), - join(thisDir, "suites", "learning", "index.node.ts"), ], platform: "node", outdir: join(thisDir, "out", "node"), From eb30e6383183e51ab9030cd9d54b60d26d329046 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Wed, 5 Aug 2026 09:59:50 -0700 Subject: [PATCH 101/101] Drop additional reference to non-existent browser tests --- source/vscode/test/runTests.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/vscode/test/runTests.mjs b/source/vscode/test/runTests.mjs index d817e157f36..bded05da5f2 100644 --- a/source/vscode/test/runTests.mjs +++ b/source/vscode/test/runTests.mjs @@ -72,7 +72,7 @@ try { } console.log("Empty suite succeeded."); - const suites = ["language-service", "debugger", "learning"]; + const suites = ["language-service", "debugger"]; const toRun = selectedSuite && suites.includes(selectedSuite) ? [selectedSuite] : suites;