From e7f3720313f5f111be20b6b9f408b60566522a3b Mon Sep 17 00:00:00 2001 From: jia xin Date: Sat, 26 Sep 2026 16:09:40 +0800 Subject: [PATCH 1/3] fix: make Start, tours, and window launch easier to follow The tours list mixed expand with navigation, Start gave no instant feedback, and the intro vanished after one click. Maximize on open, keep the tours panel expanded, and let learners hide the intro themselves. Co-authored-by: Cursor --- docs/architecture/exercises-root.md | 8 +- src/electron/ipc/gitmastery.ts | 289 ++++++++++++-------- src/electron/ipc/webContentsView.ts | 88 +++++- src/electron/main.ts | 5 + src/electron/preload.cts | 15 +- src/electron/startPrereqs.ts | 19 +- src/electron/storage.ts | 4 +- src/ui/App.tsx | 2 +- src/ui/components/Header/ToursMenu.tsx | 17 +- src/ui/components/Setup/FirstStartModal.tsx | 28 +- src/ui/providers/ActivityProvider.tsx | 16 +- types.d.ts | 15 +- 12 files changed, 341 insertions(+), 165 deletions(-) diff --git a/docs/architecture/exercises-root.md b/docs/architecture/exercises-root.md index 1f64ab4..ecbb62b 100644 --- a/docs/architecture/exercises-root.md +++ b/docs/architecture/exercises-root.md @@ -12,13 +12,13 @@ needed. Running setup from an onboarding screen asked for that work before the lesson did, and it required the CLI to be installed first. The first time a learner clicks Start, the app walks through three steps. The -introduction is shown once. The other two are checked on every Start, from the -main process, so the Start button in the app and the one in the lesson page -behave the same. +introduction repeats on every Start until they check "Don't show this again". +The other two are checked on every Start, from the main process, so the Start +button in the app and the one in the lesson page behave the same. 1. **Introduction.** A short description of an exercise: a folder of starting files, work in the terminal, then Verify. Hands-on practicals are mentioned - because they have no Verify step. + because they have no Verify step. A checkbox opts out of seeing it again. 2. **Tools.** `gitmastery` must be on PATH. On Windows, Git Bash must be installed too, because the in-app terminal uses it. Neither check runs the CLI. On Windows the step says to restart the app, because PATH is read at diff --git a/src/electron/ipc/gitmastery.ts b/src/electron/ipc/gitmastery.ts index a4af48c..05b2646 100644 --- a/src/electron/ipc/gitmastery.ts +++ b/src/electron/ipc/gitmastery.ts @@ -34,6 +34,23 @@ const START_EXERCISE_STARTED_CHANNEL = "start-exercise-started" as const; const START_EXERCISE_RESULT_CHANNEL = "start-exercise-result" as const; const VERIFY_BLOCKED_CHANNEL = "verify-blocked" as const; +type ExercisePageBusy = ( + kind: "start" | "verify", + id: string, + busy: boolean, +) => void; + +let exercisePageBusy: ExercisePageBusy | null = null; + +/** The embedded lesson page paints Start/Verify busy state from this. */ +export function onExercisePageBusy(handler: ExercisePageBusy) { + exercisePageBusy = handler; +} + +function setPageBusy(kind: "start" | "verify", id: string, busy: boolean) { + exercisePageBusy?.(kind, id, busy); +} + const isSameDirectory = (a: string, b: string): boolean => { const left = path.resolve(a); const right = path.resolve(b); @@ -292,121 +309,83 @@ export const _download = ( export const _verify = ( mainWindow: BrowserWindow, exerciseIdentifier: string, -) => { - const blocking = getBlockingPrereq(); - if (blocking) { - const message = prereqFailureMessage(blocking); - echoToXterm(`\r\n${message}\r\n`); - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: `verify`, - data: { - exerciseIdentifier, - completed: { status: "failure", message }, - }, - }); - return; - } - - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: `verify`, - data: { - exerciseIdentifier, - success: { - message: "Verifying…", - data: { stderr: "", stdout: "" }, - }, - }, +): Promise => { + setPageBusy("verify", exerciseIdentifier, true); + return runVerify(mainWindow, exerciseIdentifier).finally(() => { + setPageBusy("verify", exerciseIdentifier, false); }); +}; - const exerciseCwd = resolveReadyExerciseCwd(exerciseIdentifier); - if (exerciseCwd === null || !isSameDirectory(getCwd(), exerciseCwd)) { - sendToRenderer(mainWindow, VERIFY_BLOCKED_CHANNEL, { exerciseIdentifier }); - return; - } - - const childProcess = _spawnChildProcess({ - args: ["verify"], - cwd: exerciseCwd, - }); - const echo = startCliEcho("gitmastery verify"); - - let stdoutBuffer = ""; - let stderrBuffer = ""; - - childProcess.stdout.on("data", (data) => { - stdoutBuffer += data.toString() + "[[terminal-line]]"; - // Send progress updates to renderer - logGM("stdout", `verify`, data.toString()); - echo.write(data.toString()); - - const taskPayload: GitMasteryTaskData = { - exerciseIdentifier: exerciseIdentifier, +const runVerify = ( + mainWindow: BrowserWindow, + exerciseIdentifier: string, +): Promise => + new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; - success: { - message: data.toString(), + const blocking = getBlockingPrereq(); + if (blocking) { + const message = prereqFailureMessage(blocking); + echoToXterm(`\r\n${message}\r\n`); + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { + originalCommand: `verify`, data: { - stdout: stdoutBuffer, - stderr: stderrBuffer, + exerciseIdentifier, + completed: { status: "failure", message }, }, - }, - }; + }); + finish(); + return; + } + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { originalCommand: `verify`, - data: taskPayload, + data: { + exerciseIdentifier, + success: { + message: "Verifying…", + data: { stderr: "", stdout: "" }, + }, + }, }); - // check for SUCCESS and ERROR - }); - - childProcess.stderr.on("data", (data) => { - stderrBuffer += data.toString() + "[[terminal-line]]"; - // Send error updates to renderer - logGM("stderr", `verify`, data.toString()); - echo.write(data.toString()); - - const taskPayload: GitMasteryTaskData = { - exerciseIdentifier: exerciseIdentifier, + const exerciseCwd = resolveReadyExerciseCwd(exerciseIdentifier); + if (exerciseCwd === null || !isSameDirectory(getCwd(), exerciseCwd)) { + sendToRenderer(mainWindow, VERIFY_BLOCKED_CHANNEL, { + exerciseIdentifier, + }); + finish(); + return; + } - error: { - code: 500, // TODO: set this code properly - message: data.toString(), - }, - }; - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: `verify`, - data: taskPayload, + const childProcess = _spawnChildProcess({ + args: ["verify"], + cwd: exerciseCwd, }); - }); + const echo = startCliEcho("gitmastery verify"); - childProcess.on("error", (err) => { - echo.write(spawnFailureMessage(err)); - echo.finish(); - _reportSpawnFailure(mainWindow, "verify", exerciseIdentifier, err); - }); + let stdoutBuffer = ""; + let stderrBuffer = ""; - childProcess.on("close", (code) => { - echo.finish(); - logGM("close", `verify`, String(code)); - if (code === 0) { - // Success - - const correct = _checkCorrectSolution(stdoutBuffer); - const incorrect = _checkIncorrectSolution(stdoutBuffer); - const comments = _getComments(stdoutBuffer); + childProcess.stdout.on("data", (data) => { + stdoutBuffer += data.toString() + "[[terminal-line]]"; + // Send progress updates to renderer + logGM("stdout", `verify`, data.toString()); + echo.write(data.toString()); const taskPayload: GitMasteryTaskData = { exerciseIdentifier: exerciseIdentifier, - completed: { - status: "success", - message: "Verify finished", - stdout: stdoutBuffer, - stderr: stderrBuffer, - + success: { + message: data.toString(), data: { - correct, - incorrect, - comments, + stdout: stdoutBuffer, + stderr: stderrBuffer, }, }, }; @@ -415,32 +394,95 @@ export const _verify = ( data: taskPayload, }); - if (!exerciseIdentifier.startsWith(HANDS_ON_PREFIX)) { - patchExerciseProgress( - exerciseIdentifier, - correct ? "completed" : "in-progress", - ); - } - } else { - // Failure + // check for SUCCESS and ERROR + }); + + childProcess.stderr.on("data", (data) => { + stderrBuffer += data.toString() + "[[terminal-line]]"; + // Send error updates to renderer + logGM("stderr", `verify`, data.toString()); + echo.write(data.toString()); const taskPayload: GitMasteryTaskData = { exerciseIdentifier: exerciseIdentifier, - completed: { - status: "failure", - message: stderrBuffer || "Verify failed. Try again.", - stdout: stdoutBuffer, - stderr: stderrBuffer, + error: { + code: 500, // TODO: set this code properly + message: data.toString(), }, }; sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { originalCommand: `verify`, data: taskPayload, }); - } + }); + + childProcess.on("error", (err) => { + echo.write(spawnFailureMessage(err)); + echo.finish(); + _reportSpawnFailure(mainWindow, "verify", exerciseIdentifier, err); + finish(); + }); + + childProcess.on("close", (code) => { + if (settled) return; + echo.finish(); + logGM("close", `verify`, String(code)); + if (code === 0) { + // Success + + const correct = _checkCorrectSolution(stdoutBuffer); + const incorrect = _checkIncorrectSolution(stdoutBuffer); + const comments = _getComments(stdoutBuffer); + + const taskPayload: GitMasteryTaskData = { + exerciseIdentifier: exerciseIdentifier, + + completed: { + status: "success", + message: "Verify finished", + stdout: stdoutBuffer, + stderr: stderrBuffer, + + data: { + correct, + incorrect, + comments, + }, + }, + }; + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { + originalCommand: `verify`, + data: taskPayload, + }); + + if (!exerciseIdentifier.startsWith(HANDS_ON_PREFIX)) { + patchExerciseProgress( + exerciseIdentifier, + correct ? "completed" : "in-progress", + ); + } + } else { + // Failure + + const taskPayload: GitMasteryTaskData = { + exerciseIdentifier: exerciseIdentifier, + + completed: { + status: "failure", + message: stderrBuffer || "Verify failed. Try again.", + stdout: stdoutBuffer, + stderr: stderrBuffer, + }, + }; + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { + originalCommand: `verify`, + data: taskPayload, + }); + } + finish(); + }); }); -}; /** Incremented on every Start so a finishing download cannot steal a newer `cd`. */ let startGeneration = 0; @@ -448,6 +490,7 @@ let startGeneration = 0; const _startExercise = async ( mainWindow: BrowserWindow, exerciseIdentifier: string, + options?: { skipIntro?: boolean }, ): Promise => { // The outcome is broadcast as well as returned, so that the button injected // into the embedded lesson page, which has no return value to inspect, drives @@ -466,7 +509,7 @@ const _startExercise = async ( // Checked before taking a start generation, so a missing tool does not // cancel a download that is already changing directory. - const firstRunStep = getStartPrereqStep(); + const firstRunStep = getStartPrereqStep({ skipIntro: options?.skipIntro }); if (firstRunStep) { return report({ ok: false, exerciseIdentifier, firstRunStep }); } @@ -571,6 +614,7 @@ const startingExercises = new Map>(); export const startExercise = ( mainWindow: BrowserWindow, exerciseIdentifier: string, + options?: { skipIntro?: boolean }, ): Promise => { const inFlight = startingExercises.get(exerciseIdentifier); if (inFlight) return inFlight; @@ -578,10 +622,16 @@ export const startExercise = ( sendToRenderer(mainWindow, START_EXERCISE_STARTED_CHANNEL, { exerciseIdentifier, }); - const started = _startExercise(mainWindow, exerciseIdentifier).finally(() => { + setPageBusy("start", exerciseIdentifier, true); + const started = _startExercise( + mainWindow, + exerciseIdentifier, + options, + ).finally(() => { startingExercises.delete(exerciseIdentifier); // A download is what makes an exercise's AI Hints button usable. notifyAiHintsPageStateChanged(); + setPageBusy("start", exerciseIdentifier, false); }); startingExercises.set(exerciseIdentifier, started); return started; @@ -607,7 +657,7 @@ export function setupGitmasteryIpc(mainWindow: BrowserWindow) { void startExercise(mainWindow, commandArgs.join(" ")); break; case "verify": - _verify(mainWindow, commandArgs.join(" ")); + void _verify(mainWindow, commandArgs.join(" ")); break; default: throw new Error("Invalid command"); @@ -620,8 +670,13 @@ export function setupGitmasteryIpc(mainWindow: BrowserWindow) { // Command 2: `start` an exercise manually (this function helps the user CD into an exercise) ipcMainHandle( "gitmastery-start-exercise", - async ({ exerciseIdentifier }: { exerciseIdentifier: string }) => - startExercise(mainWindow, exerciseIdentifier), + async ({ + exerciseIdentifier, + skipIntro, + }: { + exerciseIdentifier: string; + skipIntro?: boolean; + }) => startExercise(mainWindow, exerciseIdentifier, { skipIntro }), ); } diff --git a/src/electron/ipc/webContentsView.ts b/src/electron/ipc/webContentsView.ts index c700eeb..d50c3e9 100644 --- a/src/electron/ipc/webContentsView.ts +++ b/src/electron/ipc/webContentsView.ts @@ -6,7 +6,7 @@ import { } from "electron"; import { ipcMainHandle, ipcMainOn } from "../utils/util.js"; import { getWcvPreloadPath } from "../pathResolver.js"; -import { startExercise, _verify } from "./gitmastery.js"; +import { startExercise, _verify, onExercisePageBusy } from "./gitmastery.js"; import { getMainWindow } from "../main.js"; import { sendToRenderer } from "./ipcUtils.js"; import { @@ -61,6 +61,21 @@ let sitePrefs: SiteViewPrefs | null = null; let aiHintsHandler: ((exerciseId: string) => void) | null = null; +const busyStarts = new Set(); +const busyVerifies = new Set(); + +function setPageBusy(kind: "start" | "verify", id: string, busy: boolean) { + const set = kind === "start" ? busyStarts : busyVerifies; + if (busy) set.add(id); + else set.delete(id); + if (!wcv || wcv.webContents.isDestroyed()) return; + void wcv.webContents + .executeJavaScript( + `if (typeof window.__gmSetBusy === "function") window.__gmSetBusy(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(busy)});`, + ) + .catch(() => {}); +} + /** Exercise identifiers are a path segment on disk and a selector in the page. */ const EXERCISE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/i; @@ -258,7 +273,7 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { } else if (channel === "wcv-verify-exercise") { const { exerciseId } = args[0] as { exerciseId: string }; console.log("[wcv] verify exercise clicked:", exerciseId); - _verify(mainWindow, exerciseId); + void _verify(mainWindow, exerciseId); } else if (channel === "wcv-ai-hints") { const { exerciseId } = args[0] as { exerciseId: unknown }; console.log("[wcv] ai hints clicked:", exerciseId); @@ -291,20 +306,77 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { var ICON_DOWNLOAD = ''; var ICON_CHECK = ''; var ICON_SPARKLES = ''; + var ICON_SPINNER = ''; var AI_STATE = ${JSON.stringify(getAiHintsPageState())}; + var BUSY_STATE = ${JSON.stringify({ + start: [...busyStarts], + verify: [...busyVerifies], + })}; var BASE_STYLE = "display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:6px; font-size:14px; font-weight:500; font-family:Inter,system-ui,sans-serif; cursor:pointer; line-height:20px;"; + if (!document.getElementById("gm-busy-style")) { + var spinStyle = document.createElement("style"); + spinStyle.id = "gm-busy-style"; + spinStyle.textContent = "@keyframes gm-spin { to { transform: rotate(360deg); } }"; + document.head.appendChild(spinStyle); + } + + window.__gmBusyState = { start: {}, verify: {} }; + BUSY_STATE.start.forEach(function (id) { window.__gmBusyState.start[id] = true; }); + BUSY_STATE.verify.forEach(function (id) { window.__gmBusyState.verify[id] = true; }); + + function setButtonBusy(btn, busy) { + if ((btn.dataset.gmBusy === "true") === busy) return; + btn.dataset.gmBusy = busy ? "true" : "false"; + btn.disabled = busy; + btn.setAttribute("aria-busy", busy ? "true" : "false"); + btn.style.opacity = busy ? "0.7" : "1"; + btn.style.cursor = busy ? "progress" : "pointer"; + if (busy) { + btn.dataset.gmIdleHtml = btn.innerHTML; + var label = btn.querySelector("span"); + btn.innerHTML = ICON_SPINNER + (label ? label.outerHTML : ""); + } else if (btn.dataset.gmIdleHtml) { + btn.innerHTML = btn.dataset.gmIdleHtml; + delete btn.dataset.gmIdleHtml; + } + } + + window.__gmSetBusy = function (kind, id, busy) { + if (!window.__gmBusyState[kind]) window.__gmBusyState[kind] = {}; + window.__gmBusyState[kind][id] = busy; + var attr = kind === "verify" ? "data-gm-verify" : "data-gm-start"; + document.querySelectorAll("[" + attr + '="' + CSS.escape(id) + '"]').forEach(function (btn) { + setButtonBusy(btn, busy); + }); + }; + + function applyBusyState() { + Object.keys(window.__gmBusyState.start || {}).forEach(function (id) { + if (window.__gmBusyState.start[id]) window.__gmSetBusy("start", id, true); + }); + Object.keys(window.__gmBusyState.verify || {}).forEach(function (id) { + if (window.__gmBusyState.verify[id]) window.__gmSetBusy("verify", id, true); + }); + } + function styleSolid(btn) { btn.style.cssText = BASE_STYLE + "background:" + SOLID_BG + "; color:#fff; border:none; box-shadow:0 1px 2px rgba(0,0,0,0.05);"; - btn.addEventListener("mouseenter", function () { btn.style.background = SOLID_HOVER; }); + btn.addEventListener("mouseenter", function () { + if (btn.dataset.gmBusy === "true") return; + btn.style.background = SOLID_HOVER; + }); btn.addEventListener("mouseleave", function () { btn.style.background = SOLID_BG; }); } function styleOutline(btn) { var colors = outlineColors(); btn.style.cssText = BASE_STYLE + "background:transparent; color:" + colors.text + "; border:1px solid " + OUTLINE_BORDER + ";"; - btn.addEventListener("mouseenter", function () { btn.style.background = colors.hoverBg; }); + btn.addEventListener("mouseenter", function () { + if (btn.dataset.gmBusy === "true") return; + btn.style.background = colors.hoverBg; + }); btn.addEventListener("mouseleave", function () { btn.style.background = "transparent"; }); } @@ -386,9 +458,12 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { function createStartButton(id, label) { var btn = document.createElement("button"); + btn.setAttribute("data-gm-start", id); btn.innerHTML = ICON_DOWNLOAD + '' + (label || 'Start Exercise') + ''; styleSolid(btn); btn.addEventListener("click", function () { + if (btn.dataset.gmBusy === "true") return; + window.__gmSetBusy("start", id, true); window.wcvBridge.send("wcv-start-exercise", { exerciseId: id }); }); return btn; @@ -396,9 +471,12 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { function createVerifyButton(id) { var btn = document.createElement("button"); + btn.setAttribute("data-gm-verify", id); btn.innerHTML = ICON_CHECK + 'Verify Solution'; styleOutline(btn); btn.addEventListener("click", function () { + if (btn.dataset.gmBusy === "true") return; + window.__gmSetBusy("verify", id, true); window.wcvBridge.send("wcv-verify-exercise", { exerciseId: id }); }); return btn; @@ -502,6 +580,7 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { }); injectHandsOnButtons(); + applyBusyState(); } var observedCollapses = window.__gmObservedCollapses || (window.__gmObservedCollapses = new WeakSet()); @@ -663,6 +742,7 @@ export function setupWebContentsViewIpc(mainWindow: BrowserWindow) { wcv?.setBackgroundColor(color); }); onAiHintsPageStateChange(pushAiHintsState); + onExercisePageBusy(setPageBusy); ipcMainOn( "wcv-size", ({ diff --git a/src/electron/main.ts b/src/electron/main.ts index f3da26d..79f77f0 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -32,11 +32,16 @@ app.on("ready", () => { mainWindow = new BrowserWindow({ minWidth: 1024, minHeight: 680, + show: false, backgroundColor: THEME_BACKGROUND[resolvedTheme], webPreferences: { preload: getPreloadPath(), }, }); + mainWindow.once("ready-to-show", () => { + mainWindow?.maximize(); + mainWindow?.show(); + }); setupTheme(mainWindow); setupTerminalIpc(mainWindow); setupGitmasteryIpc(mainWindow); diff --git a/src/electron/preload.cts b/src/electron/preload.cts index 2888b55..d98c8fc 100644 --- a/src/electron/preload.cts +++ b/src/electron/preload.cts @@ -31,8 +31,9 @@ contextBridge.exposeInMainWorld("electron", { setExerciseRoot: (directory: string) => ipcInvoke("set-exercise-root", { directory }), clearExerciseRoot: () => ipcInvoke("clear-exercise-root", null), - checkStartPrereqs: () => ipcInvoke("check-start-prereqs", null), - markStartIntroSeen: () => ipcInvoke("mark-start-intro-seen", null), + checkStartPrereqs: (options?: { skipIntro?: boolean }) => + ipcInvoke("check-start-prereqs", options ?? {}), + hideStartIntro: () => ipcInvoke("hide-start-intro", null), // GitMastery getDownloadedExercises: () => ipcInvoke("get-downloaded-exercises", null), @@ -47,8 +48,14 @@ contextBridge.exposeInMainWorld("electron", { ipcOn("gitmastery-task-data", (payload) => callback(payload.originalCommand, payload.data), ), - startExercise: (exerciseIdentifier: string) => - ipcInvoke("gitmastery-start-exercise", { exerciseIdentifier }), + startExercise: ( + exerciseIdentifier: string, + options?: { skipIntro?: boolean }, + ) => + ipcInvoke("gitmastery-start-exercise", { + exerciseIdentifier, + skipIntro: options?.skipIntro, + }), onStartExerciseStarted: (callback: (payload: StartExerciseStarted) => void) => ipcOn("start-exercise-started", callback), onStartExerciseResult: (callback: (result: StartExerciseResult) => void) => diff --git a/src/electron/startPrereqs.ts b/src/electron/startPrereqs.ts index 8e22538..fcc31b7 100644 --- a/src/electron/startPrereqs.ts +++ b/src/electron/startPrereqs.ts @@ -29,7 +29,7 @@ export function hasValidExerciseRoot(): boolean { return Boolean(root && isExerciseRoot(root)); } -/** The first Start step that is still outstanding, ignoring the one-time intro. */ +/** The first Start step that is still outstanding, ignoring the introduction. */ export function getBlockingPrereq(): "tools" | "folder" | null { const tools = getToolsStatus(); if (!tools.cli || tools.gitBash === false) return "tools"; @@ -39,10 +39,13 @@ export function getBlockingPrereq(): "tools" | "folder" | null { /** * Intro, then tools, then the exercises folder. Null means Start can run. - * The intro is skipped by Verify; use `getBlockingPrereq` there. + * The intro is skipped by Verify; use `getBlockingPrereq` there. A Start that + * already showed the intro this attempt passes `skipIntro`. */ -export function getStartPrereqStep(): FirstRunStep | null { - if (!getConfig().startIntroSeen) return "intro"; +export function getStartPrereqStep(options?: { + skipIntro?: boolean; +}): FirstRunStep | null { + if (!options?.skipIntro && !getConfig().hideStartIntro) return "intro"; return getBlockingPrereq(); } @@ -103,13 +106,13 @@ export function resolveExerciseRootPick( } export function setupStartPrereqIpc() { - ipcMainHandle("check-start-prereqs", async () => ({ - step: getStartPrereqStep(), + ipcMainHandle("check-start-prereqs", async (payload) => ({ + step: getStartPrereqStep({ skipIntro: payload?.skipIntro }), tools: getToolsStatus(), })); - ipcMainHandle("mark-start-intro-seen", async () => { - saveConfig({ startIntroSeen: true }); + ipcMainHandle("hide-start-intro", async () => { + saveConfig({ hideStartIntro: true }); return true; }); } diff --git a/src/electron/storage.ts b/src/electron/storage.ts index 46fea04..6e59566 100644 --- a/src/electron/storage.ts +++ b/src/electron/storage.ts @@ -17,8 +17,8 @@ interface Config { * This is the folder itself (the one containing `.gitmastery.json`), not its parent. */ exercisesRoot?: string; - /** The learner has seen the first-Start introduction. */ - startIntroSeen?: boolean; + /** When true, Start skips the introduction modal. */ + hideStartIntro?: boolean; /** Desktop + site colour preference. System follows the OS. */ theme?: SitePageTheme; /** Parent folder chosen by older builds. Migrated to `exercisesRoot` on read. */ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c28fb2d..9dd2e1e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -26,7 +26,7 @@ function App() { // Null until the learner drags the divider; until then the CSS default // keeps the split proportional to the window height. const [hintsHeight, setHintsHeight] = useState(null); - const [lessonsPanelOpened, setLessonsPanelOpened] = useState(false); + const [lessonsPanelOpened, setLessonsPanelOpened] = useState(true); const asideRef = useRef(null); const hints = useAiHintsSession(); diff --git a/src/ui/components/Header/ToursMenu.tsx b/src/ui/components/Header/ToursMenu.tsx index 56c7e70..5403f3b 100644 --- a/src/ui/components/Header/ToursMenu.tsx +++ b/src/ui/components/Header/ToursMenu.tsx @@ -3,6 +3,7 @@ import { IconChevronDown, IconChevronLeft, IconChevronRight, + IconHome, } from "@tabler/icons-react"; import type { Exercise } from "../../../types/Exercise"; import type { Lesson, Tour, TourData } from "../../../types/Tour"; @@ -12,6 +13,7 @@ import { buildLessonUrl, buildTourHomeUrl, isLessonUrlActive, + isTourHomeUrlActive, isTourUrlActive, useWebContentsView, } from "../../contexts/WebContentsViewContext"; @@ -149,10 +151,7 @@ const TourItem = ({ {opened && (
+ {Object.values(tour.lessons).map((lesson) => ( = { }; /** - * Walks a learner through the first Start: a short introduction, then the CLI - * (and Git Bash on Windows), then the exercises folder. Closing it does + * Walks a learner through Start: a short introduction, then the CLI + * (and Git Bash on Windows), then the exercises folder. The introduction + * repeats until they check "Don't show this again". Closing it does * nothing else; the next Start resumes at whichever step is still missing. */ export const FirstStartModal = ({ @@ -30,7 +32,7 @@ export const FirstStartModal = ({ onResolved: (step: FirstRunStep | null) => void; }) => { const advance = async () => { - const result = await window.electron.checkStartPrereqs(); + const result = await window.electron.checkStartPrereqs({ skipIntro: true }); onResolved(result.step); return result; }; @@ -39,8 +41,8 @@ export const FirstStartModal = ({ {step === "intro" && ( { - await window.electron.markStartIntroSeen(); + onContinue={async (hideAgain) => { + if (hideAgain) await window.electron.hideStartIntro(); await advance(); }} /> @@ -58,8 +60,13 @@ export const FirstStartModal = ({ ); }; -const IntroStep = ({ onContinue }: { onContinue: () => Promise }) => { +const IntroStep = ({ + onContinue, +}: { + onContinue: (hideAgain: boolean) => Promise; +}) => { const [busy, setBusy] = useState(false); + const hideAgainRef = useRef(null); return (
@@ -68,12 +75,15 @@ const IntroStep = ({ onContinue }: { onContinue: () => Promise }) => { on the right, then click Verify to check your answer.

Hands-on practicals work the same way, without a check at the end.

+
+ )} + +
+
+ ); +}; diff --git a/src/ui/hooks/useWalkthrough.ts b/src/ui/hooks/useWalkthrough.ts new file mode 100644 index 0000000..4f99f83 --- /dev/null +++ b/src/ui/hooks/useWalkthrough.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from "react"; +import { useLocalStorage } from "./useLocalStorage"; + +export type WalkthroughStep = "lessons" | "terminal"; + +export const WALKTHROUGH_STEPS: WalkthroughStep[] = ["lessons", "terminal"]; + +/** + * The app walkthrough: runs once on first launch, and again whenever Help is + * clicked. Null `step` means the app behaves as normal. + */ +export function useWalkthrough() { + const [seen, setSeen] = useLocalStorage({ + key: "gm-walkthrough-seen", + defaultValue: false, + }); + const [index, setIndex] = useState(() => (seen ? null : 0)); + const step = index === null ? null : WALKTHROUGH_STEPS[index]; + + const start = useCallback(() => setIndex(0), []); + + const finish = useCallback(() => { + setIndex(null); + setSeen(true); + }, [setSeen]); + + const next = useCallback(() => { + if (index === null) return; + if (index >= WALKTHROUGH_STEPS.length - 1) finish(); + else setIndex(index + 1); + }, [index, finish]); + + const back = useCallback(() => { + if (index === null) return; + setIndex(Math.max(0, index - 1)); + }, [index]); + + // The lesson page is a native view, so it is dimmed from inside the page + // rather than by a DOM overlay. + useEffect(() => { + window.electron.setEmbeddedDimmed(step === "terminal"); + }, [step]); + + return { step, index, start, next, back }; +} diff --git a/src/ui/index.css b/src/ui/index.css index 5bebcc3..49eda89 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -36,6 +36,7 @@ --color-faint: var(--gm-faint); --color-border: var(--gm-border); --color-overlay: var(--gm-overlay); + --color-dim: var(--gm-dim); --color-accent: var(--gm-accent); --color-accent-soft: var(--gm-accent-soft); --color-accent-soft-hover: var(--gm-accent-soft-hover); @@ -88,6 +89,8 @@ --gm-faint: #a3a3a3; --gm-border: #e5e5e5; --gm-overlay: rgb(23 23 23 / 0.25); + /* Walkthrough dimming. Mirrored in src/electron/ipc/webContentsView.ts for the lesson page. */ + --gm-dim: rgb(23 23 23 / 0.45); --gm-accent: #236e3d; --gm-accent-soft: #f2faf5; --gm-accent-soft-hover: #e1f4e8; @@ -129,6 +132,7 @@ html[data-theme="dark"] { --gm-faint: #adb5bd; --gm-border: #495057; --gm-overlay: rgb(0 0 0 / 0.5); + --gm-dim: rgb(0 0 0 / 0.6); --gm-accent: #75b798; --gm-accent-soft: #1f3932; --gm-accent-soft-hover: #264a40; diff --git a/types.d.ts b/types.d.ts index 9280221..3001e42 100644 --- a/types.d.ts +++ b/types.d.ts @@ -15,6 +15,8 @@ interface Window { navigate: (url: string) => void; hide: () => void; show: () => void; + /** Dims the lesson page in place, for the walkthrough. */ + setEmbeddedDimmed: (dimmed: boolean) => void; onWcvLoading: (callback: (loading: boolean) => void) => () => void; onWcvUrlChanged: (callback: (url: string) => void) => () => void; getSitePrefs: () => Promise; @@ -103,6 +105,7 @@ type IpcHandlerChannelMapping = { "wcv-show": null; "wcv-size": { x: number; y: number; width: number; height: number }; "wcv-hide": null; + "wcv-set-dimmed": { dimmed: boolean }; "wcv-loading": { loading: boolean }; "wcv-url-changed": { url: string }; "set-app-theme": {