diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index 2c3322e4873..0c26a2ec402 100644 --- a/source/vscode/ai/qdk-learning.agent.md +++ b/source/vscode/ai/qdk-learning.agent.md @@ -100,7 +100,8 @@ 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 +- **reset** ("reset this cell", "reset this exercise", "start this over") → `reset`; restores only the current activity — one `.qs` file, or one notebook cell. Confirm the user wants to lose their code before calling. +- **reset unit** ("start this notebook over", "clear my progress on this unit") → `reset-unit`; **python-notebook courses only** — it re-copies the whole notebook from the original and clears the unit's completion, so confirm explicitly and name the unit. Use `list-units` to find the `unitId` when resetting a unit the user isn't currently on. Q# courses have no unit reset; reset their exercises one at a time with `reset`. - **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. @@ -134,3 +135,4 @@ Render the result, offer a brief reaction. Don't auto-call `next` — the user m - Don't reveal the solution without a spoiler warning - Don't invent state — call `get-state` if unsure - Don't dump raw state JSON to the user +- **Don't hand-edit the learner's workbook or `.qs` files to restore them.** To undo the learner's work, call `reset` (or `reset-unit` for a whole notebook) — they copy the original content verbatim from the course source. Editing the file yourself risks writing code that was never part of the course. diff --git a/source/vscode/package.json b/source/vscode/package.json index ab4b90d50f9..c7b7c65cf4a 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -334,6 +334,10 @@ "command": "qsharp-vscode.learningResetUnit", "when": "false" }, + { + "command": "qsharp-vscode.learningOpenNotebook", + "when": "false" + }, { "command": "qsharp-vscode.learningShowActivity", "when": "false" @@ -761,6 +765,12 @@ "category": "QDK Learning", "icon": "$(discard)" }, + { + "command": "qsharp-vscode.learningOpenNotebook", + "title": "Open Course Notebook", + "category": "QDK Learning", + "icon": "$(notebook)" + }, { "command": "qsharp-vscode.learningShowActivity", "title": "Show Current Activity", @@ -1667,7 +1677,7 @@ ], "toolReferenceName": "qdkLearningReset", "displayName": "QDK Learning: Reset", - "modelDescription": "Reset the current exercise to its placeholder code and clear its completion. Destructive — requires confirmation. Only valid on exercises.", + "modelDescription": "Reset only the current activity to its original starter code and clear its completion. For python-notebook courses this restores just the current cell (an exercise cell or a plain code cell), leaving the learner's other cells intact; to reset the whole notebook use qdk-learning-reset-unit. For Q# courses this restores the current exercise's .qs file and is only valid on exercises. Destructive — requires confirmation.", "canBeReferencedInPrompt": true, "icon": "./resources/file-icon-light.svg", "inputSchema": { @@ -1677,6 +1687,30 @@ "additionalProperties": false } }, + { + "name": "qdk-learning-reset-unit", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningResetUnit", + "displayName": "QDK Learning: Reset Unit", + "modelDescription": "Reset an entire unit's notebook: re-copy it from the course original, discarding all of the learner's edits across every cell and clearing completion for the unit. Python-notebook courses only — Q# courses have no unit reset, so reset their exercises one at a time with qdk-learning-reset. Destructive — all of the user's work in the unit is lost — requires confirmation. Defaults to the current unit; pass unitId from list-units or get-state to reset a different unit in the active course.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": { + "unitId": { + "type": "string", + "description": "ID of the unit to reset (e.g. 'superposition'). Defaults to the current unit." + } + }, + "required": [], + "additionalProperties": false + } + }, { "name": "qdk-create-notebook-venv", "tags": [ diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 6a63e9bdac0..b812dfd544b 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -326,11 +326,81 @@ export class LearningTools { */ async resetExercise(): Promise { await this.ensureInitialized(); - this.throwIfNotQSharpCourse(); return this.invoke(async () => { - await this.service.resetExercise("chat"); + // Resolve the target from the editor — the selected cell for notebook + // courses — rather than the stored position, matching hint/solution. + // A destructive reset must never silently act on a different cell, so + // if a workbook is focused but its selected cell can't be identified, + // fail loudly instead of falling back to the stored position. + if (this.notebookSelectionUnidentified()) { + throw new CopilotToolError( + "I couldn't tell which cell is selected — it has no stable id yet. " + + "Click into the exercise cell you want to reset and try again, or reset the whole unit.", + ); + } + const state = this.serializeState(true); + await this.service.resetExerciseAt(state.position.location, "chat"); await this.showActivity(); - return { state: this.serializeState(false) }; // Q# only + return { state: this.serializeState(true) }; + }); + } + + /** + * True when the learner is on a course workbook but the selected cell has no + * stable id, so {@link serializeState} would fall back to the stored + * position — unsafe for reset. Judged from the active editor's URI so a + * different unit's workbook is still covered. + */ + private notebookSelectionUnidentified(): boolean { + const editor = vscode.window.activeNotebookEditor; + if (!editor || !this.service.isCourseWorkbook(editor.notebook.uri)) { + return false; + } + const selection = editor.selections[0]; + if (!selection) { + return true; + } + const cellId = editor.notebook.cellAt(selection.start).metadata?.id; + return typeof cellId !== "string"; + } + + /** + * Reset an entire unit, clearing completion for all of its activities. + * Defaults to the current unit. + */ + async resetUnit(input?: { + unitId?: string; + }): Promise<{ unitId: string; unitTitle: string } & StateSnapshot> { + await this.ensureInitialized(); + return this.invoke(async () => { + // With no explicit unit, resolve the target from the notebook the + // learner is viewing rather than the stored position: sync the position + // to the active workbook first, matching how the other tools resolve + // from the editor. Best-effort — with no workbook focused we fall back + // to the stored current unit. + if (!input?.unitId) { + const activeNotebook = vscode.window.activeNotebookEditor?.notebook.uri; + if (activeNotebook) { + await this.service.syncToWorkbook(activeNotebook); + } + } + + // Unit reset is notebook-only; the service rejects Q# courses. + const { unitId, unitTitle } = await this.service.resetUnit( + { unitId: input?.unitId }, + "chat", + ); + + // The reset closed the workbook and notebook courses don't use the + // lesson panel, so re-open the fresh copy. The open command resolves the + // notebook from the current position, so move there first — the reset + // unit isn't necessarily the one the learner was on. + await this.service.goTo({ unitId }, "chat"); + await vscode.commands.executeCommand( + "qsharp-vscode.learningOpenNotebook", + ); + + return { unitId, unitTitle, state: this.serializeState(false) }; }); } diff --git a/source/vscode/src/gh-copilot/tools.ts b/source/vscode/src/gh-copilot/tools.ts index 06ea9bd1324..480f231ed68 100644 --- a/source/vscode/src/gh-copilot/tools.ts +++ b/source/vscode/src/gh-copilot/tools.ts @@ -184,11 +184,23 @@ const toolDefinitions: { { name: "qdk-learning-reset", tool: async () => await learningTools!.resetExercise(), - confirm: () => ({ + confirm: (): vscode.PreparedToolInvocation => ({ confirmationMessages: { - title: "Reset Exercise", + title: "Reset Activity", message: - "Reset the current exercise to the original placeholder? Your code will be lost.", + "Reset the current activity to its starter code? Your code will be lost.", + }, + }), + }, + { + name: "qdk-learning-reset-unit", + tool: async (input) => await learningTools!.resetUnit(input), + confirm: (input: { unitId?: string }): vscode.PreparedToolInvocation => ({ + confirmationMessages: { + title: "Reset Unit", + message: input?.unitId + ? `Reset unit "${input.unitId}" to its original state? All of your work in this unit will be lost.` + : "Reset the current unit to its original state? All of your work in this unit will be lost.", }, }), }, diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index a5fccad35ce..f73d2c5b9c0 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -63,10 +63,24 @@ export function registerLearningCommands( await service.switchCourse(location.courseId, "tree"); } await service.goTo(location, "tree"); + } else { + // Invoked from the notebook toolbar: point the stored position at the + // notebook the learner is actually looking at before we reset by + // position, so a not-yet-synced editor switch can't reset a different + // unit than the visible one. If we can't confirm which workbook is + // active, don't guess at a destructive reset — abort. + const activeNotebook = + vscode.window.activeNotebookEditor?.notebook.uri; + if ( + !activeNotebook || + !(await service.syncToWorkbook(activeNotebook)) + ) { + return; + } } const confirmed = await vscode.window.showWarningMessage( - "Reset this unit to the original notebook? Your current work will be lost.", + "Reset this unit to its original state? Your current work in this unit will be lost.", { modal: true }, "Reset", ); @@ -74,14 +88,32 @@ export function registerLearningCommands( return; } - await service.resetExercise(); - // The whole unit was reset, so the learner's old position no longer - // means anything — start them at the top of the fresh notebook. + await service.resetUnit( + location ? { unitId: location.unitId } : undefined, + location ? "tree" : "notebook", + ); + + // Unit reset is notebook-only and closes the workbook, so re-open the + // fresh copy at the top. await openCourseNotebook(service, { reveal: "top" }); vscode.window.showInformationMessage("Unit has been reset."); }, ), + // Used by the chat tool to re-open a notebook it closed during a reset. + vscode.commands.registerCommand( + "qsharp-vscode.learningOpenNotebook", + async () => { + if ( + !service.initialized || + !isNotebookCourse(service.getActiveCourseInfo()) + ) { + return; + } + await openCourseNotebook(service, { reveal: "top" }); + }, + ), + // Progress tree commands vscode.commands.registerCommand( diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts index fd4dde50f75..ea69bbb6754 100644 --- a/source/vscode/src/learning/notebookExercises.ts +++ b/source/vscode/src/learning/notebookExercises.ts @@ -55,6 +55,10 @@ interface RawCell { /** The subset of an nbformat notebook this module reads. */ interface RawNotebook { cells?: unknown; + metadata?: { + language_info?: { name?: unknown }; + kernelspec?: { language?: unknown }; + }; } /** A {@link RawNotebook} whose `cells` array has been validated to exist. */ @@ -214,6 +218,97 @@ export function stripAuthoringCells( return `${JSON.stringify(notebook, undefined, 1)}\n`; } +/** + * The authored form of a single cell: its source, cell kind, and the + * notebook's kernel language. Reset restores all three, so a learner who + * converted the exercise cell to Markdown gets a runnable code cell back + * rather than the starter text stranded in the wrong cell type. + */ +export interface AuthoredCell { + source: string; + kind: "code" | "markdown" | "other"; + language: string | undefined; +} + +/** + * Read a single cell's authored source and kind out of a notebook's JSON, + * matched by its stable nbformat cell ID, along with the notebook's kernel + * language. + */ +export function findAuthoredCell( + text: string, + cellId: string, + unitLabel: string, +): AuthoredCell | undefined { + const notebook = parseNotebook(text, unitLabel); + if (!notebook) { + return undefined; + } + const cell = notebook.cells.find((c) => cellIdOf(c) === cellId); + if (!cell) { + return undefined; + } + return { + source: cellSource(cell), + kind: cellKind(cell), + language: notebookLanguage(notebook), + }; +} + +/** + * The notebook's kernel language (e.g. `"python"`), read from its nbformat + * metadata. Used to rebuild a code cell; `undefined` when unspecified. + */ +function notebookLanguage(notebook: ParsedNotebook): string | undefined { + const name = notebook.metadata?.language_info?.name; + if (typeof name === "string") { + return name; + } + const language = notebook.metadata?.kernelspec?.language; + return typeof language === "string" ? language : undefined; +} + +/** + * Return the notebook JSON with one cell restored to its authored source and + * kind, matched by its stable nbformat cell ID. Restoring the kind brings a + * cell the learner converted to Markdown back to a runnable code cell. A + * restored code cell is given empty run state, since it has not been run. + */ +export function replaceCellSource( + text: string, + cellId: string, + newSource: string, + newKind: "code" | "markdown" | "other", + unitLabel: string, +): string | undefined { + const notebook = parseNotebook(text, unitLabel); + if (!notebook) { + return undefined; + } + + const cell = notebook.cells.find((c) => cellIdOf(c) === cellId); + if (!cell) { + return undefined; + } + + const isCode = newKind === "code"; + cell.cell_type = isCode ? "code" : "markdown"; + cell.source = newSource; + if (isCode) { + // A restored code cell has never been run, so give it empty run state. + (cell as { outputs?: unknown }).outputs = []; + (cell as { execution_count?: unknown }).execution_count = null; + } else { + // Markdown cells carry no run state. + delete (cell as { outputs?: unknown }).outputs; + delete (cell as { execution_count?: unknown }).execution_count; + } + + // 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 ─── /** diff --git a/source/vscode/src/learning/python/materialization.ts b/source/vscode/src/learning/python/materialization.ts index db87f3164b3..d10e085a3e4 100644 --- a/source/vscode/src/learning/python/materialization.ts +++ b/source/vscode/src/learning/python/materialization.ts @@ -5,7 +5,12 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { sourceNotebookUri, workbookUri } from "../courseLayout.js"; import { ensureParentDir, uriExists } from "../fsUtils.js"; -import { stripAuthoringCells } from "../notebookExercises.js"; +import { + findAuthoredCell, + replaceCellSource, + stripAuthoringCells, + type AuthoredCell, +} from "../notebookExercises.js"; import type { NotebookCatalogCourse, NotebookCatalogUnit } from "../types.js"; /** @@ -27,21 +32,147 @@ export async function materializeCourseWorkbooks( /** * Re-materialize a single unit: overwrite its `*.workbook.ipynb` - * with a fresh copy derived from the authored notebook. + * with a fresh copy derived from the authored notebook. Returns `false` if + * the workbook could not be written. */ export async function rematerializeUnitWorkbook( unit: NotebookCatalogUnit, -): Promise { - await materializeNotebook( +): Promise { + return materializeNotebook( sourceNotebookUri(unit), workbookUri(unit), unit.id, ); } +/** + * Restore a single cell in a unit's working copy to its authored state, + * leaving the learner's other cells untouched. When the workbook is open the + * in-editor edit is authoritative — an open notebook won't reliably observe an + * external file write, and a later save would clobber it. Returns `false` when + * the cell can't be found or the restore fails. + */ +export async function restoreUnitWorkbookCell( + unit: NotebookCatalogUnit, + cellId: string, +): Promise { + try { + const srcText = new TextDecoder().decode( + await vscode.workspace.fs.readFile(sourceNotebookUri(unit)), + ); + const authored = findAuthoredCell(srcText, cellId, unit.id); + if (authored === undefined) { + log.warn( + `Cell ${cellId} not found in the authored notebook for unit "${unit.id}".`, + ); + return false; + } + + const dest = workbookUri(unit); + const open = vscode.workspace.notebookDocuments.find( + (n) => n.uri.toString() === dest.toString(), + ); + if (open) { + return replaceOpenCell(open, cellId, authored); + } + + const destText = new TextDecoder().decode( + await vscode.workspace.fs.readFile(dest), + ); + const updated = replaceCellSource( + destText, + cellId, + authored.source, + authored.kind, + unit.id, + ); + if (updated === undefined) { + log.warn( + `Cell ${cellId} not found in the workbook for unit "${unit.id}".`, + ); + return false; + } + await vscode.workspace.fs.writeFile( + dest, + new TextEncoder().encode(updated), + ); + return true; + } catch (e) { + log.warn( + `Failed to restore cell ${cellId} in unit "${unit.id}": ${String(e)}`, + ); + return false; + } +} + +/** + * Replace one cell of an open notebook with its authored source and kind, + * preserving the cell id and tags while dropping outputs and execution state. + * The in-editor edit is the reset: it takes effect the moment the cell shows + * the authored source, and the notebook is then saved to disk best-effort. + * Returns `false` only when the cell is missing or the edit is rejected. A save + * that can't complete leaves the reset cell unsaved in the editor — like any + * other pending edit — instead of undoing the reset, so we never leave the cell + * showing placeholder code and then report the reset as a failure. + */ +async function replaceOpenCell( + notebook: vscode.NotebookDocument, + cellId: string, + authored: AuthoredCell, +): Promise { + const index = notebook.getCells().findIndex((c) => c.metadata?.id === cellId); + if (index < 0) { + return false; + } + + const existing = notebook.cellAt(index); + // Restore the authored kind and language, not the learner's current ones: if + // they converted the exercise cell to Markdown, reset must bring back a + // runnable code cell. Fall back to Python — these are Python-notebook + // courses — when the notebook declares no kernel language. + const isCode = authored.kind === "code"; + const data = new vscode.NotebookCellData( + isCode ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup, + authored.source, + isCode ? (authored.language ?? "python") : "markdown", + ); + // Keep the metadata so the stable cell id and its tags survive the replace. + data.metadata = existing.metadata; + data.outputs = []; + data.executionSummary = undefined; + + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [ + vscode.NotebookEdit.replaceCells( + new vscode.NotebookRange(index, index + 1), + [data], + ), + ]); + if (!(await vscode.workspace.applyEdit(edit))) { + return false; + } + + // Persist the reset best-effort. The edit already updated the editor, so a + // save that doesn't land just leaves the cell unsaved, not un-reset; log it + // for diagnostics but still report the cell as reset. + try { + if (!(await notebook.save())) { + log.warn( + `Reset cell ${cellId} in the editor, but saving the workbook to disk didn't complete.`, + ); + } + } catch (e) { + log.warn( + `Reset cell ${cellId} in the editor, but saving the workbook to disk failed: ${String(e)}`, + ); + } + return true; +} + /** * Write a unit's working copy: the authored notebook minus its author-only - * cells (hints, solutions, explanations). + * cells (hints, solutions, explanations). Returns `false` if the copy could + * not be written. * * 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 @@ -51,7 +182,7 @@ async function materializeNotebook( src: vscode.Uri, dest: vscode.Uri, unitId: string, -): Promise { +): Promise { try { await ensureParentDir(dest); const text = new TextDecoder().decode( @@ -60,15 +191,17 @@ async function materializeNotebook( const stripped = stripAuthoringCells(text, unitId); if (stripped === undefined) { await vscode.workspace.fs.copy(src, dest, { overwrite: true }); - return; + } else { + await vscode.workspace.fs.writeFile( + dest, + new TextEncoder().encode(stripped), + ); } - await vscode.workspace.fs.writeFile( - dest, - new TextEncoder().encode(stripped), - ); + return true; } catch (e) { log.warn( `Failed to materialize ${src.fsPath} → ${dest.fsPath}: ${String(e)}`, ); + return false; } } diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 5540ec2ad14..1eb5d927d88 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -14,6 +14,7 @@ import { promptInstallPythonExtensions } from "./python/extensionUtils.js"; import { materializeCourseWorkbooks, rematerializeUnitWorkbook, + restoreUnitWorkbookCell, } from "./python/materialization.js"; import { KATAS_COURSE_ID, @@ -913,11 +914,16 @@ export class LearningService { getExerciseFileUri(): vscode.Uri { const exercise = this.resolveExercise(); + return this.exerciseFileUri(this.position.unitId, exercise.id); + } + + /** URI of the user's working copy of a specific exercise. */ + private exerciseFileUri(unitId: string, exerciseId: string): vscode.Uri { return vscode.Uri.joinPath( this.requireWorkspace().learningContentRoot, "exercises", - this.position.unitId, - `${exercise.id}.qs`, + unitId, + `${exerciseId}.qs`, ); } @@ -926,11 +932,16 @@ export class LearningService { if (activity.type !== "lesson" || !activity.example) { throw new Error("Current activity is not an example"); } + return this.exampleFileUri(unit.id, activity.example.id); + } + + /** URI of the user's working copy of a specific example. */ + private exampleFileUri(unitId: string, exampleId: string): vscode.Uri { return vscode.Uri.joinPath( this.requireWorkspace().learningContentRoot, "examples", - unit.id, - `${activity.example.id}.qs`, + unitId, + `${exampleId}.qs`, ); } @@ -944,14 +955,21 @@ export class LearningService { return new TextDecoder().decode(bytes); } - /** Save the document to disk if it's open and has unsaved edits. */ - private async saveOpenDocument(uri: vscode.Uri): Promise { + /** + * Save the document to disk if it's open and has unsaved edits. Returns + * `false` if the document is still dirty afterwards (the save didn't + * complete), so callers can avoid overwriting a file the editor would + * clobber back. + */ + private async saveOpenDocument(uri: vscode.Uri): Promise { const doc = vscode.workspace.textDocuments.find( (d) => d.uri.toString() === uri.toString(), ); - if (doc?.isDirty) { - await doc.save(); + if (!doc?.isDirty) { + return true; } + await doc.save(); + return !doc.isDirty; } async markExampleRun(): Promise { @@ -979,46 +997,126 @@ export class LearningService { } /** - * Reset the current exercise/unit to its original state and clear - * completion status. + * Reset the current exercise to its original state and clear its + * completion status. For python-notebook courses this restores just the + * current cell. */ async resetExercise(source?: TelemetrySource): Promise { - // Python-notebook courses: close the notebook, re-copy the entire unit - // from source, and clear completion. - const course = this.activeCourse; + await this.resetExerciseAt( + this.requireWorkspace().progressData.position, + source, + ); + } + + /** + * Reset the exercise at {@link location} and clear its completion — one + * `.qs` file for Q# courses, or one cell for notebook courses. Lets + * editor-aware callers target the selected cell rather than the stored + * position, mirroring {@link getHintContext} and {@link getAllSolutions}. + */ + async resetExerciseAt( + location: ActivityLocation, + source?: TelemetrySource, + ): Promise { + const ws = this.requireWorkspace(); + const course = this.requireCourse(ws, location.courseId); + if (isNotebookCourse(course)) { - const unit = this.findCourseUnit(course, this.position.unitId); - // Close any open notebook tabs for this unit. - await this.closeNotebookTab(workbookUri(unit)); - // Re-materialize the unit from source. - await rematerializeUnitWorkbook(unit); - // Clear completion for every activity in the unit, not just the - // current one, since the whole unit was re-materialized. - this.markUnitIncomplete(course.id, unit); + const unit = this.findCourseUnit(course, location.unitId); + const activity = unit.activities.find( + (a) => a.id === location.activityId, + ); + if ( + !activity || + (activity.type !== "exercise" && activity.type !== "code-cell") + ) { + throw new Error("The current activity has no code cell to reset."); + } + const restored = await restoreUnitWorkbookCell(unit, location.activityId); + if (!restored) { + throw new Error( + "Could not restore this cell. Reset the whole unit instead.", + ); + } + this.markIncomplete(location); await this.saveProgress(); this._onDidChangeState.fire(this.getState()); if (source) { - this.sendActivityActionTelemetry("reset", source); + this.sendActivityActionTelemetry("reset", source, activity.type); } return; } - const exercise = this.resolveExercise(); - const uri = this.getExerciseFileUri(); - // Save any unsaved edits first so the editor is clean, then overwrite - // the file on disk. The editor will pick up the change automatically - // because it's no longer dirty. - await this.saveOpenDocument(uri); + const exercise = this.resolveExerciseAt(location); + const uri = this.exerciseFileUri(location.unitId, exercise.id); + // Save any unsaved edits first so the editor is clean, then overwrite the + // file on disk, the editor picks up the change because it's no longer + // dirty. If the save didn't take, abort: a still-dirty editor would save + // the user's old code back over the placeholder. + if (!(await this.saveOpenDocument(uri))) { + throw new Error( + "Couldn't save your open file. Save or close it, then try again.", + ); + } await vscode.workspace.fs.writeFile( uri, new TextEncoder().encode(exercise.placeholderCode), ); - this.markIncomplete(this.requireWorkspace().progressData.position); + this.markIncomplete(location); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + if (source) { + this.sendActivityActionTelemetry("reset", source, exercise.type); + } + } + + /** + * Reset an entire unit: recopy its notebook from the course original and + * clear completion for all of its activities. Notebook courses only — a Q# + * unit is a folder of separate `.qs` files that are reset individually via + * {@link resetExercise}. Defaults to the current unit. + */ + async resetUnit( + input?: { unitId?: string }, + source?: TelemetrySource, + ): Promise<{ unitId: string; unitTitle: string }> { + const course = this.activeCourse; + if (!isNotebookCourse(course)) { + throw new Error( + "Resetting a whole unit is only supported for notebook courses. Reset Q# exercises one at a time instead.", + ); + } + const unitId = input?.unitId ?? this.position.unitId; + const unit = this.findCourseUnit(course, unitId); + // Close any open notebook tab first. If the learner cancels a + // save-on-close prompt, abort — otherwise the still-open editor could + // later save stale content back over the reset. + if (!(await this.closeNotebookTab(workbookUri(unit)))) { + throw new Error( + "Couldn't close the open notebook. Save or close it, then try again.", + ); + } + if (!(await rematerializeUnitWorkbook(unit))) { + throw new Error("Couldn't restore the unit's notebook."); + } + return this.finishUnitReset(course.id, unit, source); + } + + /** Clear the unit's completions and persist after a unit reset. */ + private async finishUnitReset( + courseId: string, + unit: CatalogUnit, + source?: TelemetrySource, + ): Promise<{ unitId: string; unitTitle: string }> { + this.markUnitIncomplete(courseId, unit); await this.saveProgress(); this._onDidChangeState.fire(this.getState()); if (source) { - this.sendActivityActionTelemetry("reset", source); + // A unit reset spans every activity in the unit, so record it as a + // unit-level action rather than borrowing the current activity's type. + this.sendActivityActionTelemetry("reset-unit", source, "unit"); } + return { unitId: unit.id, unitTitle: unit.title }; } async run( @@ -1167,10 +1265,18 @@ export class LearningService { } sendActivityActionTelemetry( - action: "navigate" | "run" | "check" | "hint" | "solution" | "reset", + action: + | "navigate" + | "run" + | "check" + | "hint" + | "solution" + | "reset" + | "reset-unit", source: TelemetrySource, + activityType: CatalogActivity["type"] | "unit" = this.findCurrentActivity() + .activity.type, ): void { - const activityType = this.findCurrentActivity().activity.type; sendTelemetryEvent( EventType.LearningActivityAction, { action, activityType, source }, @@ -1489,15 +1595,18 @@ 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. + * skipped, since they have no single URI to match against. Returns `false` + * if a matching tab could not be closed (e.g. the user cancelled a + * save-on-close prompt). */ private async closeTabs( predicate: (uri: vscode.Uri, tab: vscode.Tab) => boolean, - ): Promise { + ): Promise { const matches = this.findTabs(predicate); - if (matches.length > 0) { - await vscode.window.tabGroups.close(matches); + if (matches.length === 0) { + return true; } + return vscode.window.tabGroups.close(matches); } /** @@ -2019,10 +2128,11 @@ export class LearningService { /** * Close any open editor tabs whose URI matches the given notebook URI. + * Returns `false` if a matching tab could not be closed. */ - private async closeNotebookTab(uri: vscode.Uri): Promise { + private async closeNotebookTab(uri: vscode.Uri): Promise { const uriStr = uri.toString(); - await this.closeTabs( + return this.closeTabs( (tabUri, tab) => tab.input instanceof vscode.TabInputNotebook && tabUri.toString() === uriStr, diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index b66928debb9..6a991550e2d 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -343,8 +343,15 @@ type EventTypes = { }; [EventType.LearningActivityAction]: { properties: { - action: "navigate" | "run" | "check" | "hint" | "solution" | "reset"; - activityType: "lesson" | "exercise" | "code-cell"; + action: + | "navigate" + | "run" + | "check" + | "hint" + | "solution" + | "reset" + | "reset-unit"; + activityType: "lesson" | "exercise" | "code-cell" | "unit"; source: "panel" | "chat" | "tree" | "notebook"; }; measurements: Empty;