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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@

---

## 📁 24 septembre 2026 — Déplacer un fichier ne détruit plus rien, et Windows comprend ses propres chemins (THI-353)

*PR #392 · écarts théorie ↔ terminal : 174 → 162 · leçons montrant du bash sur Windows : 39 → 37*

Le rejeu de la théorie avait trouvé le bug le plus grave de la journée, dans le module 2.

- **`mv` et `cp` vers un dossier existant.** `mv documents/notes.txt .` remplaçait le dossier personnel entier par le fichier : l'élève perdait toute son arborescence. `cp fichier dossier` et `Move-Item fichier dossier` écrasaient de même le dossier visé. Désormais, comme dans un vrai shell, le fichier va *dans* le dossier. Plusieurs fichiers peuvent être déplacés d'un coup vers un dossier, `cp -r documents projets/` crée `projets/documents`, et un dossier ne peut pas être déplacé dans lui-même. Les options `-v`, `-n` et `-i` sont comprises. Comme le simulateur ne peut pas poser de question, `-i` répond « non » et le dit. Une option inconnue est refusée avec le message de GNU.
- **Windows comprend ses propres chemins.** `documents\notes.txt`, `.\documents\rapport.md` et `C:\Users\user\projets` échouaient tous : seul le `/` fonctionnait. PowerShell accepte les deux séparateurs, le simulateur aussi. `cd ..` depuis le dossier personnel mène à `C:\Users`, et non plus à un `C:\home` qui n'existe pas sous Windows. Les exercices acceptent les deux écritures : `Get-Content documents\notes.txt` valide l'exercice au même titre que `documents/notes.txt`. Un test le vérifie pour chaque exercice Windows qui prend un chemin. La touche Tab complète aussi après un `\`.
- **Les autres commandes Windows touchées.** `Remove-Item -Recurse` supprime un dossier avec son contenu (l'option était ignorée). `Copy-Item -Recurse` copie un dossier complet. `mkdir archives\2025` crée aussi le dossier parent, comme le fait PowerShell.
- **Des exemples qui fonctionnent tels quels.** Les leçons `mv`, `rm` et `cat` montraient des fichiers qui n'existent pas (`fichier.txt`, `[contenu de fichier1]`). Chaque exemple crée maintenant ce qu'il utilise, et l'élève Windows voit la version PowerShell.

---

## 🐚 24 septembre 2026 — Un vrai shell, et une théorie qui dit ce que le terminal répond (THI-353)

*PR #389 · #390 · #391 · 198/198 exercices sans ligne rouge (ordinateur et mobile) · nouveau test permanent `lessonTheory` : 754 commandes de théorie rejouées*
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/LessonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useLessonSEO } from '../hooks/useLessonSEO';
import { toUnixUsername } from '../../lib/username';
import { renderInlineMarkdown, stripInlineMarkdown } from '../../lib/renderInlineMarkdown';
import { TerminalState, createInitialState } from '../data/terminalEngine';
import { exerciseAccepts } from '../data/validators';
import { TerminalEmulator } from './TerminalEmulator';
import { Button } from './ui/button';
import { AiTutorPanel } from './ai/AiTutorPanel';
Expand Down Expand Up @@ -179,7 +180,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: {
const handleCommand = useCallback(
(command: string, _state: TerminalState) => {
if (!lesson.exercise || exerciseCompleted) return;
if (lesson.exercise.validate(command, selectedEnv)) {
if (exerciseAccepts(lesson.exercise.validate, command, selectedEnv)) {
completeLesson(moduleId, lessonId);
setExerciseMessage(lesson.exercise.successMessage);
setJustCompleted(true);
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/TerminalEmulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us

// Tab autocompletion. Shared by the keyboard handler and the mobile key bar.
const triggerTabCompletion = useCallback(() => {
const completions = getTabCompletions(input, activeState);
const completions = getTabCompletions(input, activeState, environment);
if (completions.length === 1) {
setInput(completions[0]);
} else if (completions.length > 1) {
Expand Down
3 changes: 1 addition & 2 deletions src/app/data/commands/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ function displayPath(cwd: string[]): string {
/** Git for Windows writes `C:/Users/user/…` (drive letter, forward slashes). */
function gitPath(cwd: string[], env: TerminalEnv): string {
if (env !== 'windows') return displayPath(cwd);
const home = cwd[0] === 'home' && cwd[1] === 'user';
return (home ? ['C:', 'Users', 'user', ...cwd.slice(2)] : ['C:', ...cwd]).join('/');
return ['C:', ...(cwd[0] === 'home' ? ['Users', ...cwd.slice(1)] : cwd)].join('/');
}

function getNode(root: DirectoryNode, path: string[]): FSNode | null {
Expand Down
33 changes: 27 additions & 6 deletions src/app/data/commands/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ export interface WindowsCmdDeps {
cmdMkdir: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode };
cmdTouch: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode };
cmdCp: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode };
cmdMv: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode };
cmdMv: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode; newCwd?: string[] };
cmdRm: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode };
cmdEcho: (args: string[], envVars?: Record<string, string>) => OutputLine[];
cmdGrep: (state: TerminalState, args: string[]) => OutputLine[];
cmdEnv: (state: TerminalState) => OutputLine[];
}

/** `-Recurse`, or any prefix PowerShell accepts for it (`-r`, `-rec`…). */
const RECURSE_PARAM = /^-r(e(c(u(r(se?)?)?)?)?)?$/i;

const EXECUTION_POLICIES = ['Restricted', 'AllSigned', 'RemoteSigned', 'Unrestricted', 'Bypass', 'Undefined', 'Default'];

/** Value that follows a PowerShell parameter (`-Verb RunAs`), case-insensitive. */
Expand All @@ -30,6 +33,19 @@ function psPath(args: string[]): string | undefined {
return i >= 0 ? args[i + 1] : args.find((a) => !a.startsWith('-'));
}

/** `Copy-Item a b`, or `-Path a -Destination b` in any order → `[a, b]`. */
function psSourceAndDestination(args: string[]): string[] {
const NAMED = ['-path', '-literalpath', '-destination'];
const valueOf = (names: string[]) => {
const i = args.findIndex((a) => names.includes(a.toLowerCase()));
return i >= 0 ? args[i + 1] : undefined;
};
const positional = args.filter((a, i) => !a.startsWith('-') && !(i > 0 && NAMED.includes(args[i - 1].toLowerCase())));
const source = valueOf(['-path', '-literalpath']) ?? positional.shift();
const destination = valueOf(['-destination']) ?? positional.shift();
return [source, destination].filter((a): a is string => a !== undefined);
}

/**
* Handles PowerShell aliases and Windows/macOS-specific commands.
* Returns null if the command is not handled by this module (caller falls through to default).
Expand Down Expand Up @@ -128,8 +144,10 @@ export function handleWindows(
case 'copy-item':
case 'cpi':
case 'copy': {
const cpArgs = args.filter((a) => !a.startsWith('-'));
const { lines, newRoot } = deps.cmdCp(newState, cpArgs);
const cpArgs = psSourceAndDestination(args);
// -Recurse copies a folder with its content, like `cp -r`.
const recurse = args.some((a) => RECURSE_PARAM.test(a));
const { lines, newRoot } = deps.cmdCp(newState, [...(recurse ? ['-r'] : []), '--', ...cpArgs]);
if (newRoot) newState = { ...newState, root: newRoot };
return { lines, newState };
}
Expand All @@ -138,9 +156,10 @@ export function handleWindows(
case 'move-item':
case 'mi':
case 'move': {
const mvArgs = args.filter((a) => !a.startsWith('-'));
const { lines, newRoot } = deps.cmdMv(newState, mvArgs);
const mvArgs = psSourceAndDestination(args);
const { lines, newRoot, newCwd } = deps.cmdMv(newState, ['--', ...mvArgs]);
if (newRoot) newState = { ...newState, root: newRoot };
if (newCwd) newState = { ...newState, cwd: newCwd };
return { lines, newState };
}

Expand All @@ -150,7 +169,9 @@ export function handleWindows(
case 'del':
case 'erase': {
const rmArgs = args.filter((a) => !a.startsWith('-'));
const { lines, newRoot } = deps.cmdRm(newState, rmArgs);
// -Recurse removes a folder with its content, like `rm -r`.
const recurse = args.some((a) => RECURSE_PARAM.test(a));
const { lines, newRoot } = deps.cmdRm(newState, recurse ? ['-r', ...rmArgs] : rmArgs);
if (newRoot) newState = { ...newState, root: newRoot };
return { lines, newState };
}
Expand Down
33 changes: 28 additions & 5 deletions src/app/data/curriculum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,17 @@ export const curriculum: Module[] = [
type: 'code',
content: '$ mv documents/notes.txt .\n$ ls\ndocuments downloads notes.txt projets',
label: 'Déplacer un fichier',
contentByEnv: {
windows: 'PS> Move-Item documents\\notes.txt .\n# notes.txt est maintenant dans C:\\Users\\user (le point « . » = ici)',
},
},
{
type: 'code',
content: '$ mv notes.txt mes-notes.txt\n$ ls\ndocuments downloads mes-notes.txt projets',
content: '$ mv documents/notes.txt documents/mes-notes.txt\n$ ls documents\nmes-notes.txt rapport.md',
label: 'Renommer un fichier',
contentByEnv: {
windows: 'PS> Move-Item documents\\notes.txt documents\\mes-notes.txt\n# Même dossier, nouveau nom : le fichier est renommé',
},
},
{
type: 'info',
Expand Down Expand Up @@ -586,23 +592,40 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ rm fichier.txt\n$ ls\ndocuments downloads projets',
content: '$ touch brouillon.txt\n$ rm brouillon.txt\n$ ls\ndocuments downloads projets',
label: 'Supprimer un fichier',
contentByEnv: {
windows: 'PS> New-Item brouillon.txt\n# PowerShell affiche les détails du fichier créé\nPS> Remove-Item brouillon.txt\n# Aucun message : le fichier est supprimé, sans passer par la corbeille',
},
},
{
type: 'code',
content: '$ rm -r dossier\n$ ls\ndocuments downloads projets',
content: '$ mkdir -p archives/2025\n$ rm -r archives\n$ ls\ndocuments downloads projets',
label: 'Supprimer un répertoire (-r)',
labelByEnv: {
windows: 'Supprimer un dossier (-Recurse)',
},
contentByEnv: {
windows: 'PS> mkdir archives\\2025\n# PowerShell affiche les détails du dossier créé\nPS> Remove-Item -Recurse archives\n# Le dossier et tout son contenu sont supprimés',
},
},
{
type: 'warning',
content:
'La commande `rm -rf /` ou `rm -rf *` peut détruire votre système entier ! Ne l\'utilisez jamais sans être absolument certain de ce que vous faites.',
contentByEnv: {
windows:
'`Remove-Item -Recurse -Force *` supprime tout le dossier courant, sans confirmation ni corbeille. Lancé depuis `C:\\` avec des droits d\'administrateur, il peut rendre Windows inutilisable. Ne l\'utilisez jamais sans être absolument certain de ce que vous faites.',
},
},
{
type: 'tip',
content:
'Utilisez `rm -i` pour demander une confirmation avant chaque suppression. C\'est une bonne habitude de sécurité.',
contentByEnv: {
windows:
'Ajoutez `-Confirm` (`Remove-Item brouillon.txt -Confirm`) pour que PowerShell demande une confirmation avant de supprimer. C\'est une bonne habitude de sécurité.',
},
},
],
exercise: {
Expand Down Expand Up @@ -642,12 +665,12 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ cat documents/notes.txt\nMes notes importantes\nTâches du jour:\n1. Apprendre les commandes bash\n2. Pratiquer la navigation',
content: '$ cat documents/notes.txt\nMes notes importantes\nTâches du jour:\n1. Apprendre les commandes bash\n2. Pratiquer la navigation\n3. Maîtriser les permissions\nFin du fichier',
label: 'Afficher un fichier',
},
{
type: 'code',
content: '$ cat fichier1.txt fichier2.txt\n[contenu de fichier1]\n[contenu de fichier2]',
content: '$ cat documents/notes.txt documents/rapport.md\n# Affiche notes.txt, puis rapport.md juste en dessous, sans séparation',
label: 'Concaténer plusieurs fichiers',
},
{
Expand Down
Loading
Loading