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/.vscode/launch.shared.json b/.vscode/launch.shared.json index c53365d5c09..6c76fcdf498 100644 --- a/.vscode/launch.shared.json +++ b/.vscode/launch.shared.json @@ -10,7 +10,7 @@ "args": [ "--profile=dev", "--extensionDevelopmentPath=${workspaceFolder}/source/vscode", - "${workspaceFolder}/samples/" + "${workspaceFolder}/source/vscode/test/suites/learning/test-workspace" ], "outFiles": [ "${workspaceFolder}/source/vscode/out/**/*.js", @@ -68,7 +68,7 @@ "--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", diff --git a/package-lock.json b/package-lock.json index 935864cfe7c..5b5677d64e3 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": "^3.1.0", "@vscode/test-web": "^0.0.81", "3dmol": "^2.5.4", @@ -2267,6 +2268,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": "3.1.0", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/test-electron/-/test-electron-3.1.0.tgz", diff --git a/package.json b/package.json index 24ed8aa161a..309f820484f 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": "^3.1.0", "@vscode/test-web": "^0.0.81", "3dmol": "^2.5.4", 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/ diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index b0f449cc46d..14de8f22ab7 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)", "Auto (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 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`). @@ -28,13 +30,30 @@ 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" | `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. +- 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 Warm, friendly tutor. Celebrate passes, encourage on failures, use natural language. @@ -68,6 +87,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 +96,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..27ee7290846 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" @@ -341,6 +345,26 @@ { "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" + }, + { + "command": "qsharp-vscode.learningNotebookExplain", + "when": "false" } ], "view/title": [ @@ -371,6 +395,32 @@ } ], "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.learningResetUnit", + "when": "view == qsharp-vscode.learningTree && viewItem == unitPython" + }, { "command": "qsharp-vscode.workspaceOpenPortal", "group": "inline", @@ -417,7 +467,26 @@ { "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": "qsharp-vscode.learningNotebookActive", + "group": "navigation@100" + }, + { + "command": "qsharp-vscode.learningResetUnit", + "when": "qsharp-vscode.learningNotebookActive", + "group": "navigation@110" + } + ], + "notebook/cell/title": [ + { + "command": "qsharp-vscode.learningNotebookExplain", + "when": "notebookType == 'jupyter-notebook' && qsharp-vscode.learningWorkspaceDetected", + "group": "inline/cell@50" } ], "explorer/context": [ @@ -694,11 +763,47 @@ "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", "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)" + }, + { + "command": "qsharp-vscode.learningNotebookExplain", + "title": "Explain", + "category": "QDK Learning", + "icon": "$(comment-discussion-sparkle)" } ], "breakpoints": [ @@ -1331,6 +1436,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/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 4d5568958ff..1b0c38b6b9a 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: Pick; position: CurrentActivity; progress: { totalActivities: number; @@ -142,12 +146,84 @@ 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 { + // TODO (acasey): ensure only one can run at a time + 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: "", // TODO (acasey): can/should we get the code in the active cell? + filePath: uri.fsPath, + }; + } const code = await this.service.readUserCode(); return { code, filePath: uri.fsPath }; }); @@ -316,6 +392,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/commands.ts b/source/vscode/src/learning/commands.ts index b0ccdcbf2ef..d56f864ac8a 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"; @@ -47,6 +48,37 @@ 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(); + await openCourseNotebook(service); + vscode.window.showInformationMessage("Unit has been reset."); + }, + ), + // Progress tree commands vscode.commands.registerCommand( @@ -56,10 +88,15 @@ 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 () => { - // 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 +112,85 @@ 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"); + + // python-notebook courses don't use the lesson panel — the notebook + // 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, { + reveal: node.kind === "unit" ? "top" : "exercise", + }); + return; + } + + await panelManager.show(); + }, + ), + + // Multi-course commands + + vscode.commands.registerCommand( + "qsharp-vscode.learningSwitchCourse", + async (node?: LearningProgressNode) => { + const courseId = await resolveCourseId(service, node); + if (!courseId) { + // This may simply indicate that the user declined to pick a course + 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): 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); + } + return; + } + await panelManager.show(); }, ), + vscode.commands.registerCommand( + "qsharp-vscode.learningCourseInfo", + async (node?: LearningProgressNode) => { + const courseId = await resolveCourseId(service, node); + if (!courseId) { + // This may simply indicate that the user declined to pick a course + 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 +212,188 @@ export function registerLearningCommands( }); }, ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningNotebookHint", + async (arg?: string | { cell: vscode.NotebookCell }) => { + if (!service.initialized) { + return; + } + + const courseInfo = service.getActiveCourseInfo(); + if (courseInfo.kind !== "python-notebook") { + return; + } + + const cellId = resolveCellId(arg); + + // Navigate to the exercise so the service state matches. + if (cellId) { + await service.goToExerciseByCellId(cellId, "notebook"); + } + + await vscode.commands.executeCommand("workbench.action.chat.open", { + query: `/qdk-learning Give me a hint`, + }); + }, + ), + + 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.isExerciseCellId(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. + * + * 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, + options?: { reveal?: "exercise" | "top" }, +): Promise { + const notebookUri = service.getCurrentCodeFileUri(); + if (!notebookUri) { + log.warn("No notebook associated with the current position."); + return; + } + const cellId = service.getCurrentExerciseCellId(); + + await vscode.commands.executeCommand( + "vscode.openWith", + notebookUri, + "jupyter-notebook", + { viewColumn: vscode.ViewColumn.Active, preview: false }, + ); + + if (options?.reveal === "top") { + revealNotebookTop(notebookUri); + } else 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(); + } +} + +/** + * 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 editor = findNotebookEditor(notebookUri); + if (!editor) { + return; + } + const cell = editor.notebook + .getCells() + .find((c) => c.metadata?.id === cellId); + if (!cell) { + log.warn(`Cell ${cellId} not found in ${notebookUri}; can't reveal it.`); + return; + } + + // 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.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": + return node.descriptor.title; case "continue": return node.activityTitle; case "activity": @@ -119,6 +407,8 @@ function nodeToLocation( node: LearningProgressNode, ): ActivityLocation | undefined { switch (node.kind) { + case "course": + return undefined; case "continue": return node.location; case "activity": @@ -138,3 +428,135 @@ 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 = 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 = service.getCourses(); + const descriptor = courses.find((c) => c.id === courseId); + if (!descriptor) { + log.warn(`Unable to show course info for unknown course ${courseId}`); + 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 { + // 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) { + 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); + // TODO (acasey): this dialog is ugly and unthemed - can we do better? + const choice = await vscode.window.showInformationMessage( + body, + { modal: true }, + ...actions, + ); + if (!choice) { + return; + } + const fix = report.fixes.find((r) => r.label === choice); + if (!fix) { + return; + } + await service.applyEnvironmentCheckFix(fix); +} diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts index 6c782a71358..987626e58dd 100644 --- a/source/vscode/src/learning/constants.ts +++ b/source/vscode/src/learning/constants.ts @@ -10,10 +10,29 @@ 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"; + +/** 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"; +/** 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/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 new file mode 100644 index 00000000000..7765dc0c73f --- /dev/null +++ b/source/vscode/src/learning/courseProvider.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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 find the courses + * they provide and parse them into memory. + * + * 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; + /** Find and parse every course this provider offers. */ + listCourses(): Promise; +} + +/** + * Aggregates multiple {@link CourseProvider}s so the service has a single + * place to ask for courses regardless of where they come from. + */ +export class CompositeCourseProvider implements CourseProvider { + readonly id = "composite-provider"; + + constructor(private readonly providers: CourseProvider[]) {} + + /** + * 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 courses: CatalogCourse[]; + try { + 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 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; + } + claimedBy.set(course.id, provider.id); + all.push(course); + } + } + return all; + } +} + +/** + * 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), + ]); +} + +/** 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 new file mode 100644 index 00000000000..516b191211d --- /dev/null +++ b/source/vscode/src/learning/dropInCourseProvider.ts @@ -0,0 +1,368 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import { + COURSE_MANIFEST_FILE, + COURSE_README_FILE, + LEARNING_COURSES_SUBDIR, + LEARNING_WORKSPACE_FOLDER, + WORKBOOK_SUFFIX, +} from "./constants.js"; +import type { CourseProvider } from "./courseProvider.js"; +import { uriExists } from "./fsUtils.js"; +import { parseNotebookExercises } from "./notebookExercises.js"; +import type { + CatalogActivity, + CatalogCourse, + CatalogExercise, + CatalogUnit, + 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; + 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`) 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 + * failing the whole load. + */ +export class DropInCourseProvider implements CourseProvider { + readonly id = "drop-in-provider"; + + constructor(private readonly workspaceRoot: vscode.Uri) {} + + async listCourses(): Promise { + const courses: CatalogCourse[] = []; + const seen = new Set(); + for (const loc of await this.discover()) { + const course = await this.parseCourse(loc); + if (!course) { + continue; + } + 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 courses; + } + + // ─── 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 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, sourceNotebookRel } = + 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, + 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), + }; + } + + /** + * 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 execute cells. + * + * 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, + unit: ManifestUnit, + ): Promise<{ + activities: CatalogActivity[]; + notebookExercises?: NotebookExerciseInfo[]; + sourceNotebookRel?: 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 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)); + 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 multiple .ipynb notebooks in ${unitDir.fsPath} - using ${notebookEntry.name}.`, + ); + break; + } + + const sourceNotebookRel = `${unit.dir}/${notebookEntry.name}`; + + const activities: CatalogActivity[] = []; + + // 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), + ); + 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. + 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.solutions, + solutionExplanation: ex.solutionExplanation, + } satisfies CatalogExercise); + } + } + + return { activities, notebookExercises, sourceNotebookRel }; + } +} + +// ─── 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 { + importChecks?: unknown; + }; + const env: CourseEnvironment = {}; + + 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; + } + 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( + 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; + } +} 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/index.ts b/source/vscode/src/learning/index.ts index 4c8cd7b2ea7..ec59720df83 100644 --- a/source/vscode/src/learning/index.ts +++ b/source/vscode/src/learning/index.ts @@ -8,6 +8,8 @@ import { } from "./codeLens.js"; import { registerLearningCommands } from "./commands.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"; @@ -30,15 +32,65 @@ export function initLearning( createLearningCodeLensProvider(), ), ); + const cellStatusBarProvider = + createNotebookCellStatusBarProvider(learningService); + context.subscriptions.push( + cellStatusBarProvider, + vscode.notebooks.registerNotebookCellStatusBarItemProvider( + "jupyter-notebook", + cellStatusBarProvider, + ), + ); + 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, + // mark complete. + if ( + !learningService.initialized || + learningService.getActiveCourseInfo().kind !== "python-notebook" + ) { + return; + } + + 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; + } + void learningService.goToExerciseByCellId(cellId, "notebook"); + if (change.executionSummary.success) { + void learningService.markExerciseCompleteByCellId(cellId); + } + } + } + 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); registerLearningWelcomeView(context, learningService); registerLearningCommands(context, learningService, panelManager); registerLessonPanelSerializer(context, panelManager); + registerNotebookSync(context, learningService); return learningService; } export type { + CourseDescriptor, + CourseKind, CurrentActivity, + EnvironmentCheckReport, HintContext, OverallProgress, RunResult, diff --git a/source/vscode/src/learning/catalog.ts b/source/vscode/src/learning/katasProvider.ts similarity index 78% rename from source/vscode/src/learning/catalog.ts rename to source/vscode/src/learning/katasProvider.ts index d313e100aa6..23857817f2a 100644 --- a/source/vscode/src/learning/catalog.ts +++ b/source/vscode/src/learning/katasProvider.ts @@ -3,17 +3,25 @@ import { getAllKatas } from "qsharp-lang/katas-md"; import { KATAS_COURSE_ID } from "./constants.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, @@ -80,5 +88,12 @@ export async function loadKatasCourse(): Promise { }), })); - return { id: KATAS_COURSE_ID, title: "Quantum Katas", units }; + 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/notebookCellStatusBar.ts b/source/vscode/src/learning/notebookCellStatusBar.ts new file mode 100644 index 00000000000..b45c8fa48b6 --- /dev/null +++ b/source/vscode/src/learning/notebookCellStatusBar.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import type { LearningService } from "./service.js"; + +/** + * 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, +): LearningCellStatusBarProvider { + return new LearningCellStatusBarProvider(service); +} + +class LearningCellStatusBarProvider + implements vscode.NotebookCellStatusBarItemProvider, vscode.Disposable +{ + private readonly _onDidChangeCellStatusBarItems = + new vscode.EventEmitter(); + readonly onDidChangeCellStatusBarItems = + this._onDidChangeCellStatusBarItems.event; + + private readonly subscription: vscode.Disposable; + + 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(), + ); + } + + dispose(): void { + this.subscription.dispose(); + this._onDidChangeCellStatusBarItems.dispose(); + } + + provideCellStatusBarItems( + cell: vscode.NotebookCell, + ): vscode.NotebookCellStatusBarItem[] { + const service = this.service; + + if (!service.initialized) { + return []; + } + + const courseInfo = service.getActiveCourseInfo(); + if (courseInfo.kind !== "python-notebook") { + return []; + } + + // Only annotate code cells that are exercises. + if (cell.kind !== vscode.NotebookCellKind.Code) { + return []; + } + + // Use the cell's stable ID from notebook metadata. + const cellId = cell.metadata?.id; + if (typeof cellId !== "string") { + return []; + } + + // Only show the hint button for cells that are exercises. + if (!service.isExerciseCellId(cellId)) { + return []; + } + + 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/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts new file mode 100644 index 00000000000..154cfc27f6b --- /dev/null +++ b/source/vscode/src/learning/notebookExercises.ts @@ -0,0 +1,353 @@ +// 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; +} + +/** 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. + * + * 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 { + const notebook = parseNotebook(text, unitLabel); + if (!notebook) { + return undefined; + } + + notebook.cells = notebook.cells.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 ─── + +/** + * 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; + } 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 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[] { + 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/notebookSync.ts b/source/vscode/src/learning/notebookSync.ts new file mode 100644 index 00000000000..5aa3cade72a --- /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 materialize + // 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/panel.ts b/source/vscode/src/learning/panel.ts index 0977b169b92..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, @@ -47,15 +48,25 @@ export class LessonPanelManager { private readonly service: LearningService, ) {} + /** + * 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 && + this.service.getActiveCourseInfo().kind === "python-notebook" + ); + } + /** * 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( @@ -64,20 +75,20 @@ 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", { 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 = { @@ -111,8 +122,18 @@ 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 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); @@ -148,10 +169,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(() => {}); }), ); } @@ -160,7 +187,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(); @@ -179,11 +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.getState() }); + this.sendMessage({ command: "state", state }); } /** @@ -199,7 +249,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) { @@ -207,9 +256,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. @@ -224,45 +270,19 @@ 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, ): void { - if (!this.service.initialized) { + const state = this.panelState(); + if (!state) { return; } this.sendMessage({ command: "result", action, result, - state: this.service.getState(), + state, } as Extract); } @@ -304,6 +324,30 @@ 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") { + // TODO (acasey): we might want to rename some of the commands and tools for consistency + await vscode.commands.executeCommand( + "qsharp-vscode.learningSwitchCourse", + ); + return; + } + if (msg.command === "action") { await this.handleAction(msg.action); } @@ -376,6 +420,27 @@ export class LessonPanelManager { ); } + /** + * Webview options for the lesson panel. + * + * 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 { + return { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + 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,13 +480,14 @@ export class LessonPanelManager { private async checkSolutionAndSendResult( source?: TelemetrySource, ): Promise { - const { result, state } = await this.service.checkSolution(source); - this.sendMessage({ - command: "result", - action: "check", - result, - state, - }); + const { result } = await this.service.checkSolution(source); + // 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/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts index 08d12b27791..a62968818cb 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,49 @@ 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"; + // TODO (acasey): is this valuable? It just adds " - Python environment" to the tooltip + 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}`, @@ -90,7 +130,10 @@ class LearningProgressTreeProvider implements vscode.TreeDataProvider { + // Root: one node per available course. + if (!node) { + if (!this.service.initialized) { + return []; + } + let descriptors: CourseDescriptor[]; + try { + descriptors = 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 = 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. + // Notebook courses don't have a meaningful per-activity position, so the + // shortcut is only shown for Q# courses. + if (isActive && descriptor.kind !== "python-notebook") { + 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; + const currentUnitId = this.snapshot?.currentPosition.unitId; + const currentActivityId = this.snapshot?.currentPosition.activityId; + const isActiveCourse = + this.service.initialized && + node.courseId === this.service.getActiveCourseId(); return node.unit.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,8 @@ export type LearningProgressNode = // ─── Tree node icons ─── +const iconCourse = new vscode.ThemeIcon("mortar-board"); +const iconPython = new vscode.ThemeIcon("notebook"); 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..4737fc767e1 --- /dev/null +++ b/source/vscode/src/learning/python/environment.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import type { + PythonEnvironment, + PythonEnvironmentApi, + PythonProcess, +} from "@vscode/python-environments"; +import { PythonEnvironments } from "@vscode/python-environments"; +import * as vscode from "vscode"; + +/** + * Manages per-course Python environments for `python-notebook` courses. + * + * 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 { + /** Cached Python Environments extension API (only set on success). */ + private _pythonEnvApi: PythonEnvironmentApi | undefined; + /** Cached environments keyed by courseRoot.toString(). */ + private readonly _projectEnvironmentMap = new Map< + 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). */ + get supported(): boolean { + return vscode.env.uiKind !== vscode.UIKind.Web; + } + + /** + * Ensure a Python environment exists for the given course and install the + * 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). + */ + async ensureEnvironment(courseRoot: vscode.Uri): Promise { + 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( + "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. + let env = await this.findEnvironment(api, courseRoot); + + if (env) { + log.info( + `Using existing environment for ${courseRoot.fsPath}: ${env.name}`, + ); + } else { + // 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) { + log.warn( + `Failed to create a Python environment in ${courseRoot.fsPath}. ` + + `Ensure the Python Environments extension has a registered environment manager.`, + ); + return; + } + + // 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, + }); + } + + this._projectEnvironmentMap.set(courseRoot.toString(), env); + } + + /** + * Whether an environment exists for the given course root. + */ + async environmentExists(courseRoot: vscode.Uri): Promise { + if (!this.supported) { + return false; + } + const api = await this.pythonEnvironmentsApi(); + if (!api) { + return false; + } + const env = await this.findEnvironment(api, courseRoot); + return env !== undefined; + } + + /** + * 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, + modules: string[], + ): Promise<{ module: string; ok: boolean }[]> { + if (!this.supported || modules.length === 0) { + return modules.map((module) => ({ module, ok: false })); + } + 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 runPython(api, env, ["-c", `import ${module}`]); + results.push({ module, ok: code === 0 }); + } + return results; + } + + // ─── Private helpers ─── + + /** + * 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< + PythonEnvironmentApi | undefined + > { + if (this._pythonEnvApi) { + return this._pythonEnvApi; + } + try { + this._pythonEnvApi = await PythonEnvironments.api(); + } catch (e) { + log.warn(`Python Environments extension is not available: ${String(e)}`); + } + return this._pythonEnvApi; + } + + /** + * Find an existing environment in the given directory. + */ + private async findEnvironment( + api: PythonEnvironmentApi, + courseRoot: vscode.Uri, + ): Promise { + // Check cache first. + const cached = this._projectEnvironmentMap.get(courseRoot.toString()); + if (cached) { + return cached; + } + + await api.refreshEnvironments(courseRoot); + const envs = await api.getEnvironments(courseRoot); + + switch (envs.length) { + case 0: + return undefined; + case 1: + return envs[0]; + default: + log.warn( + `Found multiple virtual environments, using first: ${envs.join(", ")}`, + ); + return envs[0]; + } + } +} + +// ─── Helpers ─── + +/** + * 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); + }); + }); +} 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..cdb5e1ea7e8 --- /dev/null +++ b/source/vscode/src/learning/python/materialization.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "qsharp-lang"; +import * as vscode from "vscode"; +import { + notebookUnits, + sourceNotebookUri, + workbookUri, +} from "../courseLayout.js"; +import { ensureParentDir, uriExists } from "../fsUtils.js"; +import { stripAuthoringCells } from "../notebookExercises.js"; +import type { CatalogCourse } from "../types.js"; + +/** + * 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 { + for (const unit of notebookUnits(course)) { + const dest = workbookUri(course, unit); + if (await uriExists(dest)) { + continue; + } + await materializeNotebook(sourceNotebookUri(course, unit), 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 { + const unit = course.units.find((u) => u.id === unitId); + if (!unit) { + throw new Error(`Unit "${unitId}" not found in course "${course.id}".`); + } + await materializeNotebook( + sourceNotebookUri(course, unit), + workbookUri(course, unit), + 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)}`, + ); + } +} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index e4b42e3d351..78ceb8650c5 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1,18 +1,31 @@ // 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"; import { ProgramRunStatus, runProgram } from "../run.js"; import { EventType, sendTelemetryEvent } from "../telemetry.js"; -import { loadKatasCourse } from "./catalog.js"; +import { createCourseProvider, toDescriptor } from "./courseProvider.js"; +import { courseRootUri, workbookUri } from "./courseLayout.js"; +import { EnvironmentManager } from "./python/environment.js"; import { + checkPythonExtensions, + promptInstallPythonExtensions, +} from "./python/extensionUtils.js"; +import { + materializeCourseWorkbooks, + rematerializeUnitWorkbook, +} from "./python/materialization.js"; +import { + KATAS_COURSE_ID, LEARNING_FILE, LEARNING_WORKSPACE_DETECTED_CONTEXT, LEARNING_WORKSPACE_FOLDER, LEARNING_WORKSPACE_RELATIVE_PATH, } from "./constants.js"; +import { ensureParentDir, uriExists } from "./fsUtils.js"; import type { ActionGroup, ActivityContent, @@ -22,7 +35,12 @@ import type { CatalogExercise, CatalogActivity, CatalogUnit, + CourseDescriptor, CurrentActivity, + EnvironmentCheckFix, + EnvironmentCheckItem, + EnvironmentCheckReport, + EnvironmentStatus, ExerciseContent, HintContext, LearningState, @@ -38,6 +56,33 @@ import type { UnitProgress, UnitSummary, } 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, + 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 +135,8 @@ 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; + /** Every course found at load time, keyed by course id. */ + courses: Map; progressData: ProgressFileData; } @@ -106,13 +151,25 @@ export class LearningService { >(); readonly onDidChangeProgress = this._onDidChangeProgress.event; - private _lastSnapshot: OverallProgress | undefined; 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; private _progressLoadingError: string | 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; @@ -127,6 +184,19 @@ 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 per-course Python environment manager. */ + private get environment(): EnvironmentManager { + if (!this._environment) { + this._environment = new EnvironmentManager(); + } + return this._environment; + } + /** * Try to initialize the service. Returns `true` when ready, `false` * when no learning workspace could be found (or created). @@ -136,32 +206,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. + + 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 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. + 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 { @@ -177,6 +289,10 @@ export class LearningService { this._onDidChangeState.dispose(); this._onDidChangeProgress.dispose(); this._progressFileWatcher?.dispose(); + this._environment?.dispose(); + for (const d of this._disposables) { + d.dispose(); + } } /** Force a fresh progress reload from disk. */ @@ -209,6 +325,7 @@ export class LearningService { * The payload sent to the webview. */ getState(): LearningState { return { + course: this.getActiveCourseInfo(), position: this.getCurrentActivity(), actions: this.getAvailableActions(), progress: this.getProgress(), @@ -261,7 +378,8 @@ export class LearningService { 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}`); } @@ -274,7 +392,7 @@ export class LearningService { ); } ws.progressData.position = { - courseId: ws.catalog.id, + courseId: course.id, unitId: location.unitId, activityId: activity.id, }; @@ -287,17 +405,499 @@ export class LearningService { return state; } - listUnits(): UnitSummary[] { + /** + * 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 goToExerciseByCellId( + cellId: string, + 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.cellId === cellId); + 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 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 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.cellId === cellId); + if (!exercise) { + log.warn(`Unable to find exercise corresponding to cell ${cellId}`); + 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; + } + + /** + * 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.sourceNotebookRel) { + continue; + } + const workbook = workbookUri(course, unit); + 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 `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. + */ + isExerciseCellId(cellId: string): boolean { + if (this.activeCourse.kind !== "python-notebook") { + return false; + } + const unit = this.findUnit(this.position.unitId); + return unit.notebookExercises?.some((ex) => ex.cellId === cellId) ?? false; + } + + /** + * 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. */ + getCourses(): CourseDescriptor[] { + return [...this.requireWorkspace().courses.values()].map(toDescriptor); + } + + /** 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(): Pick { + 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 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, + options?: { force?: boolean }, + ): Promise { + if (course.kind !== "python-notebook") { + return; + } + const env = this.environment; + if (!env.supported) { + return; + } + if (!course.sourceDir) { + return; + } + const courseRoot = courseRootUri(course); + if (!options?.force && (await env.environmentExists(courseRoot))) { + return; + } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Setting up the environment for "${course.title}"…`, + }, + async () => { + await env.ensureEnvironment(courseRoot); + }, + ); + } + + /** + * 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.ensureEnvironment(this.activeCourse, { force: true }); + return; + case "install-extensions": + await promptInstallPythonExtensions(); + 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 = courseRootUri(course); + + const checks: EnvironmentCheckItem[] = []; + + // 1. Required extensions (Python + Jupyter). + const extMessage = checkPythonExtensions(); + 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. The per-course environment. + const envExists = await env.environmentExists(courseRoot); + checks.push( + 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 + : [{ label: "Set up environment", kind: "setup" }], + }), + ); + + // 3. Required packages import in the environment. + const importChecks = course.environment?.importChecks ?? []; + if (envExists && importChecks.length > 0) { + const report = await env.importsReport(courseRoot, importChecks); + 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 if (importChecks.length > 0) { + 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, 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(); + 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 + await promptInstallPythonExtensions(); + 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(); + 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) { + // TODO (acasey): reuse firstIncompleteInUnit + 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, }) @@ -323,14 +923,28 @@ export class LearningService { } getProgress(): OverallProgress { + return this.computeProgress(this.activeCourse); + } + + /** + * 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. + */ + getCourseProgress(courseId: string): OverallProgress { + const ws = this.requireWorkspace(); + return this.computeProgress(this.requireCourse(ws, courseId)); + } + + 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, }); @@ -366,8 +980,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; @@ -375,10 +992,6 @@ export class LearningService { return { result: null, state: this.getState() }; } - if (source) { - this.sendActivityActionTelemetry("hint", source); - } - return { result: { hints, solutionExplanation }, state: this.getState(), @@ -386,11 +999,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 { @@ -444,6 +1057,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.sourceNotebookRel) { + return workbookUri(this.activeCourse, unit); + } + return undefined; + } const { activity } = this.findCurrentActivity(); if (activity.type === "exercise") { return this.getExerciseFileUri(); @@ -455,10 +1076,31 @@ 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.sourceNotebookRel) { + await this.closeNotebookTab(workbookUri(this.activeCourse, unit)); + } + // Re-materialize the unit from source. + 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); + 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 @@ -485,10 +1127,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(); @@ -498,6 +1136,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) { @@ -526,8 +1183,27 @@ export class LearningService { this.sendActivityActionTelemetry("check", source); } + // 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: { + passed: false, + messages: [], + error: + "This course uses native notebook execution. " + + "Run the exercise cell in the notebook — each cell that " + + "succeeds marks that exercise 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. @@ -684,11 +1360,16 @@ export class LearningService { throw err; } this.startWatcher(); + // TODO (acasey): make sure we're firing this an appropriate number of times sendTelemetryEvent( EventType.LearningSessionStarted, { 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; } @@ -719,6 +1400,7 @@ export class LearningService { { isFirstTime: "true" }, {}, ); + this._onDidChangeState.fire(this.getState()); return true; } @@ -728,7 +1410,14 @@ export class LearningService { ): Promise { const learningFile = vscode.Uri.joinPath(workspaceRoot, LEARNING_FILE); - const course = await loadKatasCourse(); + const courseProvider = createCourseProvider(workspaceRoot); + + // 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(); + for (const course of await courseProvider.listCourses()) { + courses.set(course.id, course); + } // Build workspace state; assigned to this.workspace only after all // async setup succeeds so that `initialized` stays false on failure. @@ -736,26 +1425,32 @@ export class LearningService { workspaceRoot, learningContentRoot: katasRoot, learningFile, - catalog: course, - progressData: { - version: 1, - position: { - courseId: course.id, - unitId: course.units[0]?.id ?? "", - activityId: course.units[0]?.activities[0]?.id ?? "", - }, - completions: {}, - startedAt: new Date().toISOString(), - }, + courses, + progressData: this.defaultProgressData(courses), }; - await this.scaffoldExercises(ws); - await this.scaffoldExamples(ws); await this.loadProgress(ws); - // All async setup succeeded — publish the workspace. + // 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 failure here should not block workspace initialization. + log.warn(`Failed to materialize course ${course.title}`); + } + } } private requireWorkspace(): WorkspaceState { @@ -767,6 +1462,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", @@ -790,6 +1499,7 @@ export class LearningService { /** Builds the button groups shown in the webview toolbar for the current activity. */ private getAvailableActions(): ActionGroup[] { const { activity } = this.findCurrentActivity(); + const primary = this.getPrimaryAction(); const primaryLabel: Record = { @@ -854,6 +1564,51 @@ export class LearningService { ); } + /** + * 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. + */ + 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; + const tabUri = + input instanceof vscode.TabInputText || + input instanceof vscode.TabInputNotebook + ? input.uri + : undefined; + if (tabUri && predicate(tabUri, tab)) { + matches.push(tab); + } + } + } + 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, @@ -863,6 +1618,18 @@ 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. + // 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", + content: activity.description, + } satisfies LessonTextContent; + } + const fileUri = vscode.Uri.joinPath( ws.learningContentRoot, "exercises", @@ -930,13 +1697,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, }; @@ -953,15 +1720,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, }; @@ -971,9 +1738,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}`); } @@ -987,7 +1752,12 @@ 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? + // 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; const exercises = @@ -1011,17 +1781,8 @@ export class LearningService { try { bytes = await vscode.workspace.fs.readFile(ws.learningFile); } catch { - // File doesn't exist yet — use defaults. - ws.progressData = { - version: 1, - position: { - courseId: ws.catalog.id, - unitId: ws.catalog.units[0]?.id ?? "", - activityId: ws.catalog.units[0]?.activities[0]?.id ?? "", - }, - completions: {}, - startedAt: new Date().toISOString(), - }; + // In this case, leave ws (and, in particular, ws.ProgressData) + // untouched, since it's either the last known state or the default. return; } @@ -1067,9 +1828,12 @@ export class LearningService { } ws.progressData = parsed as ProgressFileData; - // Validate saved position references a known unit and activity - if (ws.catalog.units.length > 0) { - const unit = ws.catalog.units.find( + + // Validate saved position references a known course, unit, and activity. + // Accept the passed-in default if they're not valid. + const course = ws.courses.get(ws.progressData.position.courseId); + if (course && course.units.length > 0) { + const unit = course.units.find( (k) => k.id === ws.progressData.position.unitId, ); const activityValid = @@ -1078,15 +1842,50 @@ export class LearningService { (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: ws.catalog.id, - unitId: ws.catalog.units[0].id, - activityId: ws.catalog.units[0].activities[0]?.id ?? "", + 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 ?? "", + unitId: course?.units[0]?.id ?? "", + activityId: course?.units[0]?.activities[0]?.id ?? "", + }, + completions: {}, + startedAt: new Date().toISOString(), + }; + } + private async saveProgress(): Promise { const ws = this.requireWorkspace(); const json = JSON.stringify(ws.progressData, null, 2); @@ -1147,6 +1946,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; @@ -1164,7 +1974,6 @@ export class LearningService { // File removed externally — tear down all workspace state. this.workspace = undefined; this.syncContextKey(); - this._lastSnapshot = undefined; this._onDidChangeProgress.fire(undefined); }; @@ -1180,74 +1989,73 @@ 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()); } - private async scaffoldExercises(ws: WorkspaceState): Promise { - for (const kata of ws.catalog.units) { - for (const activity of kata.activities) { - if (activity.type !== "exercise") { - continue; - } - 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), - ); - } - } + /** + * Close any open editor tabs whose URI matches the given notebook URI. + */ + private async closeNotebookTab(uri: vscode.Uri): Promise { + const uriStr = uri.toString(); + await this.closeTabs( + (tabUri, tab) => + tab.input instanceof vscode.TabInputNotebook && + tabUri.toString() === uriStr, + ); } - private async scaffoldExamples(ws: WorkspaceState): Promise { - for (const kata of ws.catalog.units) { + /** + * 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, + 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 materializeCourseWorkbooks(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 uriExists(fileUri)) { + continue; + } + await 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 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), - ); } } } - - 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 - } - } } diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index fbbb51a15cc..7c3de42de85 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 ─── @@ -136,6 +136,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 +217,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 +264,97 @@ export interface CatalogLesson { export type CatalogActivity = CatalogExercise | CatalogLesson; +/** + * 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[]; + /** 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; +} + export interface CatalogUnit { id: string; title: string; activities: CatalogActivity[]; + /** + * Exercise metadata for python-notebook courses, parsed from the authored + * notebook's cell tags. 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. + */ + 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[]; + /** + * 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; + /** Optional path (URI string) to a README rendered for "Course info". */ + readmePath?: string; + /** Environment requirements (python-notebook courses). */ + environment?: CourseEnvironment; +} + +/** + * 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; + 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). + * Used for things that can't be specified in `pyproject.toml`. + */ +export interface CourseEnvironment { + /** + * 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 +365,51 @@ 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. + */ + kind: "setup" | "install-extensions"; +} + +/** 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/src/telemetry.ts b/source/vscode/src/telemetry.ts index 7f63a04b938..0a022d0fbaa 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/runTests.mjs b/source/vscode/test/runTests.mjs index c2d8df49529..bded05da5f2 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. 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/test-workspace/qdk-learning/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 new file mode 100644 index 00000000000..60b132b365d --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/_unit.py @@ -0,0 +1,18 @@ +"""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, +) + +# Register this unit's exercises. +register_value_exercise("forty_two", expected=42) 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 new file mode 100644 index 00000000000..316c785fe34 --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/01-intro/intro.ipynb @@ -0,0 +1,232 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0928290d", + "metadata": {}, + "source": [ + "# Getting Started\n", + "\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", + "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!\n" + ] + }, + { + "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": { + "tags": [ + "exercise" + ] + }, + "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": "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", + "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.\")" + ] + } + ], + "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/source/vscode/test/suites/learning/test-workspace/qdk-learning/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 new file mode 100644 index 00000000000..3363a057f09 --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/_unit.py @@ -0,0 +1,78 @@ +"""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, +) + + +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/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 new file mode 100644 index 00000000000..22944e4690b --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/02-circuits/circuits.ipynb @@ -0,0 +1,311 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3db5183b", + "metadata": {}, + "source": [ + "# 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", + "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" + ] + }, + { + "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": { + "tags": [ + "exercise" + ] + }, + "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": "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", + "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": { + "tags": [ + "exercise" + ] + }, + "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": "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", + "metadata": {}, + "source": [ + "## Summary\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" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} 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 new file mode 100644 index 00000000000..668bfdabbe6 --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/README.md @@ -0,0 +1,29 @@ +# 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. + +## 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. +- 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/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 new file mode 100644 index 00000000000..8697cc4539f --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_check_env.py @@ -0,0 +1,208 @@ +"""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_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_path.read_text()) + env_cfg = course.get("environment", {}) + 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_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: + results.append(("Course venv", f"{expected_venv} — not found", False)) + errors.append( + "The course virtual environment does not exist yet.
" + "Click the Run Course Diagnostics button in the notebook toolbar " + "and choose Set up environment." + ) + 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.
" + "Click the Run Course Diagnostics button in the notebook toolbar " + "and choose Set up environment to recreate it." + ) + 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( + "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." + ) + + # --- Check 4: required packages --- + missing = [m for m in import_checks if not _can_import(m)] + + if missing: + results.append( + ("Packages", ", ".join(f"{m} missing" for m in missing), False) + ) + errors.append( + "Install missing packages by running this in a new cell, then re-run this one:" + f"
  %pip install -r ../requirements.txt
" + "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)) + + # --- Render --- + _render(results, errors) + + if errors: + raise EnvironmentError( + "Environment check failed. See output above for details." + ) + + +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 = [ + venv / "bin" / "python", + venv / "bin" / "python3", + venv / "Scripts" / "python.exe", + ] + for c in candidates: + if c.exists(): + return c + return None + + +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 = ( + "var(--vscode-testing-iconPassed, #00ff00)" + if ok + else "var(--vscode-testing-iconFailed, #ff00ff)" + ) + rows += ( + '' + 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/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 new file mode 100644 index 00000000000..c2fb5c19264 --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/_course_lib.py @@ -0,0 +1,171 @@ +"""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. +""" + +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) 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 new file mode 100644 index 00000000000..d9ed3544866 --- /dev/null +++ b/source/vscode/test/suites/learning/test-workspace/qdk-learning/courses/circuit-diagrams-new/course.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "circuit-diagrams", + "title": "Generating Circuit Diagrams", + "shortDescription": "Build and visualize quantum circuits with the QDK in Python notebooks.", + "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/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