Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/architecture/ai-context-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 10 additions & 4 deletions docs/architecture/exercise-directory-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand All @@ -164,9 +165,14 @@ must be directories, not merely exist:

Hands-on practices (`gitmastery download hp-<name>`) 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
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture/exercises-root.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions src/electron/exerciseManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-";

Expand Down Expand Up @@ -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 };
}

Expand Down
22 changes: 6 additions & 16 deletions src/ui/components/AiHints/AiHintsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,11 @@ const KIND_LABEL: Record<AiHintsKind, string> = {
"hands-on": "Hands-on",
};

/** Openers that model the kind of question that earns a useful hint. */
const SUGGESTIONS: Record<AiHintsKind, string[]> = {
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<AiHintsKind, string> = {
exercise: "Hints only, never the full solution. AI can make mistakes.",
Expand Down Expand Up @@ -181,7 +171,7 @@ const AiHintsConversation = ({
Stuck on this {noun}?
</h3>
<div className="grid w-full grid-cols-1 gap-2 @sm:grid-cols-2">
{SUGGESTIONS[session.kind].map((suggestion) => (
{SUGGESTIONS.map((suggestion) => (
<button
key={suggestion}
type="button"
Expand Down
102 changes: 24 additions & 78 deletions src/ui/components/Setup/FirstStartModal.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox";
import { Modal } from "../ui/Modal";
import { Spinner } from "../ui/Spinner";
import {
GIT_PREP_SETUP_URL,
useWebContentsView,
} from "../../contexts/WebContentsViewContext";
import { ExerciseFolderPanel } from "./ExerciseFolderPanel";

const TITLES: Record<FirstRunStep, string> = {
intro: "Before you start",
tools: "Install the tools",
const TITLES: Record<Exclude<FirstRunStep, "intro">, string> = {
tools: "Git-Mastery CLI was not found.",
folder: "Choose your exercises folder",
};

Expand All @@ -23,10 +21,12 @@ const TITLES: Record<FirstRunStep, string> = {
*/
export const FirstStartModal = ({
step,
activityName,
onClose,
onResolved,
}: {
step: FirstRunStep;
activityName: string;
onClose: () => void;
/** Called with the next outstanding step, or null when Start can run. */
onResolved: (step: FirstRunStep | null) => void;
Expand All @@ -37,8 +37,10 @@ export const FirstStartModal = ({
return result;
};

const title = step === "intro" ? `Starting ${activityName}` : TITLES[step];

return (
<Modal opened onClose={onClose} title={TITLES[step]} size="sm">
<Modal opened onClose={onClose} title={title} size="sm">
{step === "intro" && (
<IntroStep
onContinue={async (hideAgain) => {
Expand All @@ -47,9 +49,7 @@ export const FirstStartModal = ({
}}
/>
)}
{step === "tools" && (
<ToolsStep onRecheck={advance} onOpenGuide={onClose} />
)}
{step === "tools" && <ToolsStep onOpenGuide={onClose} />}
{step === "folder" && (
<ExerciseFolderPanel
onSaved={() => void advance()}
Expand Down Expand Up @@ -93,77 +93,23 @@ const IntroStep = ({
);
};

const ToolsStep = ({
onRecheck,
onOpenGuide,
}: {
onRecheck: () => Promise<{ step: FirstRunStep | null; tools: ToolsStatus }>;
onOpenGuide: () => void;
}) => {
const ToolsStep = ({ onOpenGuide }: { onOpenGuide: () => void }) => {
const { navigate } = useWebContentsView();
const [tools, setTools] = useState<ToolsStatus | null>(null);
const [checking, setChecking] = useState(false);

useEffect(() => {
window.electron
.checkStartPrereqs({ skipIntro: true })
.then((result) => setTools(result.tools))
.catch(() => setTools(null));
}, []);

const showRecheck = tools !== null && tools.gitBash === null;

return (
<div className="flex flex-col gap-4 text-sm text-fg">
{tools === null ? (
<Spinner size={20} />
) : (
<ul className="flex flex-col gap-1 text-[13px]">
<ToolLine label="Git-Mastery CLI" ok={tools.cli} />
{tools.gitBash !== null && (
<ToolLine label="Git Bash" ok={tools.gitBash} />
)}
</ul>
)}

{tools !== null && (
<p className="text-[13px] text-muted">
{showRecheck
? "Install anything missing, then check again."
: "Install anything missing, then quit and reopen the app so it can see the new tools."}
</p>
)}

<div className="flex flex-wrap justify-end gap-2">
{showRecheck && (
<Button
variant="secondary"
loading={checking}
onClick={() => {
setChecking(true);
void onRecheck()
.then((result) => setTools(result.tools))
.finally(() => setChecking(false));
}}
>
Check again
</Button>
)}
<Button
onClick={() => {
navigate(GIT_PREP_SETUP_URL);
onOpenGuide();
}}
>
Setup instructions
</Button>
</div>
</div>
<p className="text-sm text-muted">
Download and set up gitmastery{" "}
<button
type="button"
onClick={() => {
navigate(GIT_PREP_SETUP_URL);
onOpenGuide();
}}
className="text-accent underline hover:cursor-pointer focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
here
</button>
.
</p>
);
};

const ToolLine = ({ label, ok }: { label: string; ok: boolean }) => (
<li className={ok ? "text-muted" : "text-fg"}>
{ok ? `${label} is installed.` : `${label} was not found.`}
</li>
);
10 changes: 9 additions & 1 deletion src/ui/providers/ActivityProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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<Set<string>>(new Set());
Expand Down Expand Up @@ -245,6 +247,12 @@ export function ActivityProvider({ children }: { children: ReactNode }) {
{gate && (
<FirstStartModal
step={gate.step}
activityName={formatActivityName(
gate.exerciseIdentifier,
Object.values(exercisesQuery.data ?? {}).find(
(exercise) => exercise.identifier === gate.exerciseIdentifier,
),
)}
onClose={() => setGate(null)}
onResolved={onGateResolved}
/>
Expand Down
7 changes: 7 additions & 0 deletions src/ui/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading