diff --git a/docs/architecture/ai-context-providers.md b/docs/architecture/ai-context-providers.md index 870a977..c51238c 100644 --- a/docs/architecture/ai-context-providers.md +++ b/docs/architecture/ai-context-providers.md @@ -74,9 +74,9 @@ so by the time a hint is requested it can point anywhere. See `exercise-director for the same problem in `verify`. `ExerciseLocation` carries the exercises root, the exercise root and the working folder (where Start -`cd`s). For hands-on practicals the working folder is the exercise root, but the repository usually -sits one level down (`hp-init-repo/things`). The git provider therefore searches rather than -assuming (§5). +`cd`s). For hands-on practicals Start `cd`s into the single subdirectory when there is one +(`hp-init-repo/things`); otherwise it stays at the exercise root. The git provider still searches +rather than assuming (§5), because some practicals create the repository later. ## 4. What is never sent diff --git a/docs/architecture/exercise-directory-resolution.md b/docs/architecture/exercise-directory-resolution.md index 3e04d50..f36cf08 100644 --- a/docs/architecture/exercise-directory-resolution.md +++ b/docs/architecture/exercise-directory-resolution.md @@ -154,7 +154,8 @@ must be directories, not merely exist: | `exerciseRoot` does not exist | `{ state: "not-downloaded" }` | | `exerciseRoot` exists but is not a directory | `{ state: "corrupt", exerciseRoot }` | | Identifier starts with `hp-` and the directory is empty | `{ state: "incomplete", exerciseRoot }` | -| Identifier starts with `hp-` and the directory has files | `{ state: "ready", cwd: exerciseRoot }` | +| Identifier starts with `hp-` and one non-dot subdirectory | `{ state: "ready", cwd: that subdir }` | +| Identifier starts with `hp-` otherwise | `{ state: "ready", cwd: exerciseRoot }` | | Manifest missing, unparseable, or missing `exercise_repo` | `{ state: "corrupt", exerciseRoot }` | | `repo_type === "ignore"` | `{ state: "ready", cwd: exerciseRoot }` | | `repo_name` is not a single path segment | `{ state: "corrupt", exerciseRoot }` | @@ -164,9 +165,14 @@ must be directories, not merely exist: Hands-on practices (`gitmastery download hp-`) are set up by a Python `download` function rather than from a manifest (`_download_hands_on` in `app/commands/download.py`), so no -`.gitmastery-exercise.json` is ever written for them and the learner works at the exercise root. -Without the prefix check they would all resolve as `corrupt`. An empty leftover from a failed -hands-on download is `incomplete` so Start will not silently `cd` into it and skip retry. +`.gitmastery-exercise.json` is ever written for them. Without the prefix check they would all +resolve as `corrupt`. An empty leftover from a failed hands-on download is `incomplete` so +Start will not silently `cd` into it and skip retry. + +The sandbox repo is almost always the single subdirectory (`hp-init-repo/things`, +`hp-view-commits/things`, a clone). That is the same role as an exercise's `repo_name`, and +the lessons tell the learner to `cd hp-…/things`. Start therefore `cd`s into that subdirectory +when there is exactly one. Zero or several non-dot subdirectories stay at the exercise root. `repo_name` is read off disk and joined onto the exercise root, so it is rejected unless it is a single path segment — the resolved cwd is `cd`-ed into by Start and, once the terminal is diff --git a/docs/architecture/exercises-root.md b/docs/architecture/exercises-root.md index ecbb62b..6911706 100644 --- a/docs/architecture/exercises-root.md +++ b/docs/architecture/exercises-root.md @@ -21,9 +21,10 @@ button in the app and the one in the lesson page behave the same. 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 - launch and a retry cannot see a change. On macOS and Linux the step can be - checked again immediately. + CLI. The step is a short dialog: title says the CLI was not found, and + "here" opens T1L2 (`/lessons/gitPrep/#installing-the-git-mastery-app`) then + closes so the lesson is visible. The next Start re-checks. On Windows, PATH + is read at launch, so a newly installed CLI is invisible until restart. 3. **Folder.** The learner picks the exercises folder. It is accepted when it contains `.gitmastery.json`. If they pick the parent and exactly one subfolder is a root, that subfolder is used. The copy says this step does diff --git a/src/electron/exerciseManifest.ts b/src/electron/exerciseManifest.ts index 2e5571e..2454580 100644 --- a/src/electron/exerciseManifest.ts +++ b/src/electron/exerciseManifest.ts @@ -27,7 +27,8 @@ export const EXERCISE_MANIFEST_NAME = ".gitmastery-exercise.json"; /** * Hands-on practices are set up by a download script rather than a manifest, so - * there is nothing to read and the learner works at the exercise root. + * there is nothing to read. The learner works in the single subdirectory when + * there is one (the sandbox repo), otherwise at the exercise root. */ export const HANDS_ON_PREFIX = "hp-"; @@ -107,11 +108,22 @@ export function resolveExerciseCwd(exerciseRoot: string): ExerciseCwdResult { if (!isDirectory(exerciseRoot)) return { state: "corrupt", exerciseRoot }; if (path.basename(exerciseRoot).startsWith(HANDS_ON_PREFIX)) { - // Hands-on practices have no manifest, so an empty leftover from a failed - // download is indistinguishable from a successful one except by contents. - if (fs.readdirSync(exerciseRoot).length === 0) { + // Hands-on downloads have no manifest. The sandbox repo is almost always + // the single subdirectory (`things`, a clone, …), the same role as an + // exercise's `repo_name`. Lessons tell the learner to `cd hp-…/things`. + const entries = fs.readdirSync(exerciseRoot, { withFileTypes: true }); + if (entries.length === 0) { return { state: "incomplete", exerciseRoot }; } + const subdirs = entries.filter( + (entry) => entry.isDirectory() && !entry.name.startsWith("."), + ); + if (subdirs.length === 1 && isPathSegment(subdirs[0].name)) { + return { + state: "ready", + cwd: path.join(exerciseRoot, subdirs[0].name), + }; + } return { state: "ready", cwd: exerciseRoot }; } diff --git a/src/ui/components/AiHints/AiHintsPanel.tsx b/src/ui/components/AiHints/AiHintsPanel.tsx index 7f53def..d616195 100644 --- a/src/ui/components/AiHints/AiHintsPanel.tsx +++ b/src/ui/components/AiHints/AiHintsPanel.tsx @@ -30,21 +30,11 @@ const KIND_LABEL: Record = { "hands-on": "Hands-on", }; -/** Openers that model the kind of question that earns a useful hint. */ -const SUGGESTIONS: Record = { - exercise: [ - "What is this exercise asking me to do?", - "I'm stuck. What should I look at next?", - "Does my repository look right so far?", - "Explain the Git concept behind this exercise", - ], - "hands-on": [ - "What will this hands-on teach me?", - "Which step am I on, based on my repo?", - "My last command didn't work. What went wrong?", - "Explain the current step in simpler terms", - ], -}; +/** Same openers for exercises and hands-on. */ +const SUGGESTIONS = [ + "I'm stuck. What should I do next?", + "Explain the underlying concepts to me", +]; const DISCLAIMER: Record = { exercise: "Hints only, never the full solution. AI can make mistakes.", @@ -181,7 +171,7 @@ const AiHintsConversation = ({ Stuck on this {noun}?
- {SUGGESTIONS[session.kind].map((suggestion) => ( + {SUGGESTIONS.map((suggestion) => ( - )} - -
- +

+ Download and set up gitmastery{" "} + + . +

); }; - -const ToolLine = ({ label, ok }: { label: string; ok: boolean }) => ( -
  • - {ok ? `${label} is installed.` : `${label} was not found.`} -
  • -); diff --git a/src/ui/providers/ActivityProvider.tsx b/src/ui/providers/ActivityProvider.tsx index b076f58..1a59ddd 100644 --- a/src/ui/providers/ActivityProvider.tsx +++ b/src/ui/providers/ActivityProvider.tsx @@ -17,8 +17,9 @@ import { useElectronStream } from "../hooks/useElectronStream"; import { useLocalExercises } from "../hooks/query/useLocalExercises"; import { useToast, type ToastOptions } from "../contexts/ToastContext"; import { ActivityContext } from "../contexts/ActivityContext"; -import { isHandsOnIdentifier } from "../utils/format"; import { FirstStartModal } from "../components/Setup/FirstStartModal"; +import { useExercises } from "../hooks/query/useExercises"; +import { formatActivityName, isHandsOnIdentifier } from "../utils/format"; const isVerifyCommand = (cmd: string) => cmd.startsWith("verify"); @@ -32,6 +33,7 @@ export function ActivityProvider({ children }: { children: ReactNode }) { const { showToast, updateToast, hideToast } = useToast(); const { downloadedExerciseData, patchExerciseStatus } = useLocalExercises(); + const { query: exercisesQuery } = useExercises(); /** Loading toasts currently on screen, so settle can update them in place. */ const openActionToasts = useRef>(new Set()); @@ -245,6 +247,12 @@ export function ActivityProvider({ children }: { children: ReactNode }) { {gate && ( exercise.identifier === gate.exerciseIdentifier, + ), + )} onClose={() => setGate(null)} onResolved={onGateResolved} /> diff --git a/src/ui/utils/format.ts b/src/ui/utils/format.ts index 1cb9005..c660308 100644 --- a/src/ui/utils/format.ts +++ b/src/ui/utils/format.ts @@ -27,6 +27,13 @@ export const formatExerciseTitle = (exercise: Exercise) => { return formatExerciseIdentifier(exercise.identifier); }; +/** Display name for an exercise or hands-on, from its identifier and optional catalog entry. */ +export const formatActivityName = (identifier: string, exercise?: Exercise) => { + if (isHandsOnIdentifier(identifier)) return formatHandsOnTitle(identifier); + if (exercise) return formatExerciseTitle(exercise); + return formatExerciseIdentifier(identifier); +}; + export const getExerciseLessonName = (exercise: Exercise) => { return exercise.lesson?.lesson_name ?? exercise.detour?.lesson?.lesson_name; };