practicode
Advanced tools
@@ -85,2 +85,7 @@ { | ||
| "update_available": "Update available", | ||
| "update_current_version": "current", | ||
| "update_now": "Update now", | ||
| "update_later": "Later", | ||
| "update_skip_version": "Skip this version", | ||
| "update_prompt_help": "Up/Down: select · Enter: confirm · Esc: later", | ||
| "update_none": "practicode is up to date.", | ||
@@ -130,3 +135,2 @@ "update_check_disabled": "Update check is disabled.", | ||
| "learning_complete_body": "Today's guided session is complete. Use /next to browse lessons or /home to choose another mode.", | ||
| "learning_run_gate": "Next: use /next until Exercise, then /run or F5.", | ||
| "progress_title": "Learning progress", | ||
@@ -133,0 +137,0 @@ "progress_language": "Language", |
@@ -84,2 +84,7 @@ { | ||
| "update_available": "Hay una nueva versión", | ||
| "update_current_version": "actual", | ||
| "update_now": "Actualizar ahora", | ||
| "update_later": "Más tarde", | ||
| "update_skip_version": "Omitir esta versión", | ||
| "update_prompt_help": "Arriba/Abajo: elegir · Enter: confirmar · Esc: más tarde", | ||
| "update_none": "practicode está actualizado.", | ||
@@ -130,3 +135,2 @@ "update_check_disabled": "La comprobación de actualizaciones está desactivada.", | ||
| "learning_complete_body": "La sesión guiada de hoy está completa. Usa /next para explorar lecciones o /home para elegir otro modo.", | ||
| "learning_run_gate": "Siguiente: usa /next hasta Ejercicio y luego /run o F5.", | ||
| "progress_title": "Progreso de aprendizaje", | ||
@@ -133,0 +137,0 @@ "progress_language": "Lenguaje", |
@@ -84,2 +84,7 @@ { | ||
| "update_available": "新しいバージョンがあります", | ||
| "update_current_version": "現在", | ||
| "update_now": "今すぐ更新", | ||
| "update_later": "後で", | ||
| "update_skip_version": "このバージョンをスキップ", | ||
| "update_prompt_help": "上/下: 選択 · Enter: 決定 · Esc: 後で", | ||
| "update_none": "practicode は最新です。", | ||
@@ -130,3 +135,2 @@ "update_check_disabled": "更新確認は無効です。", | ||
| "learning_complete_body": "今日のガイド付きセッションは完了です。/next でレッスンを閲覧するか、/home で別のモードを選んでください。", | ||
| "learning_run_gate": "次: 演習まで /next で進み、その後 /run または F5 を使ってください。", | ||
| "progress_title": "学習進捗", | ||
@@ -133,0 +137,0 @@ "progress_language": "言語", |
@@ -85,2 +85,7 @@ { | ||
| "update_available": "새 버전 사용 가능", | ||
| "update_current_version": "현재", | ||
| "update_now": "지금 업데이트", | ||
| "update_later": "나중에", | ||
| "update_skip_version": "이 버전 건너뛰기", | ||
| "update_prompt_help": "위/아래: 선택 · Enter: 확인 · Esc: 나중에", | ||
| "update_none": "practicode가 최신 버전입니다.", | ||
@@ -130,3 +135,2 @@ "update_check_disabled": "업데이트 확인이 꺼져 있습니다.", | ||
| "learning_complete_body": "오늘의 안내 세션을 마쳤습니다. /next로 레슨을 둘러보거나 /home에서 다른 모드를 선택하세요.", | ||
| "learning_run_gate": "다음: 실습 단계까지 /next로 이동한 뒤 /run 또는 F5를 사용하세요.", | ||
| "progress_title": "학습 진도", | ||
@@ -133,0 +137,0 @@ "progress_language": "언어", |
@@ -84,2 +84,7 @@ { | ||
| "update_available": "有新版本可用", | ||
| "update_current_version": "当前", | ||
| "update_now": "立即更新", | ||
| "update_later": "稍后", | ||
| "update_skip_version": "跳过此版本", | ||
| "update_prompt_help": "上/下: 选择 · Enter: 确认 · Esc: 稍后", | ||
| "update_none": "practicode 已是最新版本。", | ||
@@ -130,3 +135,2 @@ "update_check_disabled": "更新检查已禁用。", | ||
| "learning_complete_body": "今天的引导学习已完成。使用 /next 浏览课程,或使用 /home 选择其他模式。", | ||
| "learning_run_gate": "下一步:用 /next 进入练习步骤,然后使用 /run 或 F5。", | ||
| "progress_title": "学习进度", | ||
@@ -133,0 +137,0 @@ "progress_language": "语言", |
@@ -1,40 +0,47 @@ | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="1112" height="630" viewBox="0 0 1112 630" role="img" aria-label="Practicode · Home"> | ||
| <rect width="1112" height="630" rx="14" fill="#0f172a"/> | ||
| <rect x="1" y="1" width="1110" height="628" rx="13" fill="none" stroke="#334155"/> | ||
| <circle cx="18" cy="18" r="5" fill="#fb7185"/><circle cx="34" cy="18" r="5" fill="#fbbf24"/><circle cx="50" cy="18" r="5" fill="#4ade80"/> | ||
| <text x="70" y="23" fill="#94a3b8" font-size="13" font-family="ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace">Practicode · Home</text> | ||
| <g fill="#e5e7eb" font-size="14" font-family="ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace" xml:space="preserve"> | ||
| <text x="16" y="48">┌> Continue today's session──────────────────────┐┌Preview─────────────────────────────────────────────────────────────┐</text> | ||
| <text x="16" y="66">│Review what's due, learn one core item, and ││Continue today's session │</text> | ||
| <text x="16" y="84">│validate the exercise. ││ │</text> | ||
| <text x="16" y="102">│ ││Language: Python │</text> | ||
| <text x="16" y="120">│ ││Progress: 0/12 │</text> | ||
| <text x="16" y="138">│ ││Due: 0 │</text> | ||
| <text x="16" y="156">└────────────────────────────────────────────────┘│Next step: Language delta │</text> | ||
| <text x="16" y="174">┌Practice coding tests───────────────────────────┐│ │</text> | ||
| <text x="16" y="192">│Solve stdin/stdout coding-test problems. ││ │</text> | ||
| <text x="16" y="210">│ ││ │</text> | ||
| <text x="16" y="228">│ ││ │</text> | ||
| <text x="16" y="246">│ ││ │</text> | ||
| <text x="16" y="264">│ ││ │</text> | ||
| <text x="16" y="282">└────────────────────────────────────────────────┘│ │</text> | ||
| <text x="16" y="300">Arrows choose | Enter/Space open | / commands │ │</text> | ||
| <text x="16" y="318"> │ │</text> | ||
| <text x="16" y="336"> │ │</text> | ||
| <text x="16" y="354"> │ │</text> | ||
| <text x="16" y="372"> │ │</text> | ||
| <text x="16" y="390"> │ │</text> | ||
| <text x="16" y="408"> │ │</text> | ||
| <text x="16" y="426"> │ │</text> | ||
| <text x="16" y="444"> │ │</text> | ||
| <text x="16" y="462"> │ │</text> | ||
| <text x="16" y="480"> │ │</text> | ||
| <text x="16" y="498"> │ │</text> | ||
| <text x="16" y="516"> │ │</text> | ||
| <text x="16" y="534"> │ │</text> | ||
| <text x="16" y="552"> │ │</text> | ||
| <text x="16" y="570"> └────────────────────────────────────────────────────────────────────┘</text> | ||
| <text x="16" y="588"> F1 help | arrows choose | Enter open </text> | ||
| <text x="16" y="606">Command: Type /, move with up/down, Enter runs </text> | ||
| </g> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="1112" height="630" viewBox="0 0 1112 630" role="img" aria-label="Actual Practicode Home TUI render"> | ||
| <title>Practicode · Home</title> | ||
| <style> | ||
| .panel { fill: none; stroke-width: 1.5; } | ||
| .active-border { stroke: #facc15; } | ||
| .border { stroke: #22d3ee; } | ||
| .text { fill: #e5e7eb; font: 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .chrome { fill: #94a3b8; font: 13px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .active-title { fill: #facc15; font: 700 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .title { fill: #22d3ee; font: 700 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .status { fill: #c8d3f5; font: 700 13px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| </style> | ||
| <rect width="1112" height="630" rx="14" fill="#0f172a"/> | ||
| <rect y="34" width="1112" height="596" fill="#111827"/> | ||
| <rect x="1" y="1" width="1110" height="628" rx="13" fill="none" stroke="#334155"/> | ||
| <circle cx="18" cy="18" r="5" fill="#fb7185"/> | ||
| <circle cx="34" cy="18" r="5" fill="#fbbf24"/> | ||
| <circle cx="50" cy="18" r="5" fill="#4ade80"/> | ||
| <text class="chrome" x="70" y="23">Practicode · Home</text> | ||
| <rect class="panel active-border" x="20.5" y="43" width="441" height="108"/> | ||
| <rect x="24" y="35" width="230" height="17" fill="#111827"/> | ||
| <text class="active-title" x="25" y="48">> Continue today's session</text> | ||
| <text class="text" x="25" y="66">Review what's due, learn one core item, and</text> | ||
| <text class="text" x="25" y="84">validate the exercise.</text> | ||
| <rect class="panel border" x="20.5" y="169" width="441" height="108"/> | ||
| <rect x="24" y="161" width="190" height="17" fill="#111827"/> | ||
| <text class="title" x="25" y="174">Practice coding tests</text> | ||
| <text class="text" x="25" y="192">Solve stdin/stdout coding-test problems.</text> | ||
| <rect class="panel border" x="470.5" y="43" width="621" height="522"/> | ||
| <rect x="474" y="35" width="75" height="17" fill="#111827"/> | ||
| <text class="title" x="475" y="48">Preview</text> | ||
| <text class="text" x="475" y="66">Continue today's session</text> | ||
| <text class="text" x="475" y="102">Language: Python</text> | ||
| <text class="text" x="475" y="120">Progress: 0/12</text> | ||
| <text class="text" x="475" y="138">Due: 0</text> | ||
| <text class="text" x="475" y="156">Next step: Language delta</text> | ||
| <text class="text" x="16" y="300">Arrows choose | Enter/Space open | / commands</text> | ||
| <rect y="574" width="1112" height="18" fill="#152033"/> | ||
| <text class="status" x="25" y="588">F1 help | arrows choose | Enter open</text> | ||
| <text class="text" x="16" y="606">Command: Type /, move with up/down, Enter runs</text> | ||
| </svg> |
@@ -1,40 +0,62 @@ | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="1112" height="630" viewBox="0 0 1112 630" role="img" aria-label="Practicode · Learn"> | ||
| <rect width="1112" height="630" rx="14" fill="#0f172a"/> | ||
| <rect x="1" y="1" width="1110" height="628" rx="13" fill="none" stroke="#334155"/> | ||
| <circle cx="18" cy="18" r="5" fill="#fb7185"/><circle cx="34" cy="18" r="5" fill="#fbbf24"/><circle cx="50" cy="18" r="5" fill="#4ade80"/> | ||
| <text x="70" y="23" fill="#94a3b8" font-size="13" font-family="ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace">Practicode · Learn</text> | ||
| <g fill="#e5e7eb" font-size="14" font-family="ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace" xml:space="preserve"> | ||
| <text x="16" y="48">┌> Lesson────────────────────────────────────────┐┌Code · Exercise.rs──────────────────────────────────────────────────┐</text> | ||
| <text x="16" y="66">│Language delta: Make stdout byte-exact ││> 1 use std::io::{self, Read}; │</text> | ||
| <text x="16" y="84">│ ││ 2 │</text> | ||
| <text x="16" y="102">│Unlike string concatenation in many languages, ││ 3 fn render(name: &str, score: &str) -> String { │</text> | ||
| <text x="16" y="120">│Rust formatting keeps the template and typed ││ 4 // TODO: use the exact field separator required by stdout. │</text> | ||
| <text x="16" y="138">│arguments separate and validates their ││ 5 format!("{name} {score}") │</text> | ||
| <text x="16" y="156">│relationship. ││ 6 } │</text> | ||
| <text x="16" y="174">│ ││ 7 │</text> | ||
| <text x="16" y="192">│ ││ 8 fn main() { │</text> | ||
| <text x="16" y="210">│ ││ 9 let mut input = String::new(); │</text> | ||
| <text x="16" y="228">│ ││ 10 io::stdin().read_to_string(&mut input).unwrap(); │</text> | ||
| <text x="16" y="246">│ ││ 11 let mut fields = input.split_whitespace(); │</text> | ||
| <text x="16" y="264">│ ││ 12 let name = fields.next().unwrap(); │</text> | ||
| <text x="16" y="282">│ ││ 13 let score = fields.next().unwrap(); │</text> | ||
| <text x="16" y="300">│ ││ 14 println!("{}", render(name, score)); │</text> | ||
| <text x="16" y="318">│ ││ 15 } │</text> | ||
| <text x="16" y="336">│ ││ 16 │</text> | ||
| <text x="16" y="354">│ ││ │</text> | ||
| <text x="16" y="372">│ ││ │</text> | ||
| <text x="16" y="390">│ ││ │</text> | ||
| <text x="16" y="408">│ ││ │</text> | ||
| <text x="16" y="426">│ ││ │</text> | ||
| <text x="16" y="444">│ ││ │</text> | ||
| <text x="16" y="462">│ ││ │</text> | ||
| <text x="16" y="480">│ ││ │</text> | ||
| <text x="16" y="498">│ ││ │</text> | ||
| <text x="16" y="516">│ ││ │</text> | ||
| <text x="16" y="534">│ ││ │</text> | ||
| <text x="16" y="552">│ ││ │</text> | ||
| <text x="16" y="570">└────────────────────────────────────────────────┘└────────────────────────────────────────────────────────────────────┘</text> | ||
| <text x="16" y="588"> F1 help | F5 run | F6 view | /next step </text> | ||
| <text x="16" y="606">Command: Type /, move with up/down, Enter runs </text> | ||
| </g> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="1112" height="630" viewBox="0 0 1112 630" role="img" aria-label="Actual Practicode guided Learn TUI render"> | ||
| <title>Practicode · Learn</title> | ||
| <style> | ||
| .panel { fill: none; stroke-width: 1.5; } | ||
| .active-border { stroke: #facc15; } | ||
| .border { stroke: #22d3ee; } | ||
| .text { fill: #e5e7eb; font: 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .chrome { fill: #94a3b8; font: 13px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .active-title { fill: #facc15; font: 700 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .title { fill: #22d3ee; font: 700 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .lesson-title { fill: #facc15; font: 700 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .section { fill: #22d3ee; font: 700 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .code { fill: #f3f4f6; font: 14px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| .status { fill: #c8d3f5; font: 700 13px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } | ||
| </style> | ||
| <rect width="1112" height="630" rx="14" fill="#0f172a"/> | ||
| <rect y="34" width="1112" height="596" fill="#111827"/> | ||
| <rect x="1" y="1" width="1110" height="628" rx="13" fill="none" stroke="#334155"/> | ||
| <circle cx="18" cy="18" r="5" fill="#fb7185"/> | ||
| <circle cx="34" cy="18" r="5" fill="#fbbf24"/> | ||
| <circle cx="50" cy="18" r="5" fill="#4ade80"/> | ||
| <text class="chrome" x="70" y="23">Practicode · Learn</text> | ||
| <rect class="panel active-border" x="20.5" y="43" width="441" height="522"/> | ||
| <rect x="24" y="35" width="80" height="17" fill="#111827"/> | ||
| <text class="active-title" x="25" y="48">> Lesson</text> | ||
| <rect class="panel border" x="470.5" y="43" width="621" height="522"/> | ||
| <rect x="474" y="35" width="158" height="17" fill="#111827"/> | ||
| <text class="title" x="475" y="48">Code · Exercise.py</text> | ||
| <text class="lesson-title" x="25" y="66">1/4 · Language delta: Exact output with print</text> | ||
| <text class="text" x="25" y="102">Unlike a raw stream write, print supplies both a</text> | ||
| <text class="text" x="25" y="120">separator and a line ending. Establishing this</text> | ||
| <text class="text" x="25" y="138">boundary early makes every later Python</text> | ||
| <text class="text" x="25" y="156">exercise's output contract explicit.</text> | ||
| <text class="section" x="25" y="192">Exercise</text> | ||
| <text class="text" x="25" y="228">Read the name and score, then configure print so</text> | ||
| <text class="text" x="25" y="246">they form one colon-delimited record terminated</text> | ||
| <text class="text" x="25" y="264">by exactly one newline.</text> | ||
| <text class="section" x="25" y="300">Input</text> | ||
| <rect x="43" y="322" width="60" height="18" fill="#1f2937"/> | ||
| <text class="code" x="43" y="336"> Ada 7 </text> | ||
| <text class="section" x="25" y="372">Output</text> | ||
| <rect x="43" y="394" width="60" height="18" fill="#1f2937"/> | ||
| <text class="code" x="43" y="408"> Ada:7 </text> | ||
| <text class="text" x="475" y="66">> 1 import sys</text> | ||
| <text class="text" x="475" y="84"> 2</text> | ||
| <text class="text" x="475" y="102"> 3 name, score = sys.stdin.read().split()</text> | ||
| <text class="text" x="475" y="120"> 4 # TODO: choose print separators and a line ending for the requi</text> | ||
| <text class="text" x="475" y="138"> 5 print(name, score)</text> | ||
| <text class="text" x="475" y="156"> 6</text> | ||
| <rect y="574" width="1112" height="18" fill="#152033"/> | ||
| <text class="status" x="25" y="588">Enter: Next</text> | ||
| <text class="text" x="16" y="606">Command: Type /, move with up/down, Enter runs</text> | ||
| </svg> |
+29
-1
@@ -28,2 +28,3 @@ const { spawnSync } = require("node:child_process"); | ||
| const STALE_TEMPORARY_FILE_MS = 60 * 60 * 1000; | ||
| const NPM_UPDATE_EXIT_CODE = 42; | ||
| const RELEASE_ROOT = "https://github.com/baba9811/practicode/releases/download"; | ||
@@ -66,2 +67,12 @@ | ||
| function npmUpdateCommand(platform = process.platform, env = process.env) { | ||
| if (platform === "win32") { | ||
| return { | ||
| command: env.ComSpec || env.COMSPEC || "cmd.exe", | ||
| args: ["/d", "/s", "/c", "npm.cmd update -g practicode"], | ||
| }; | ||
| } | ||
| return { command: "npm", args: ["update", "-g", "practicode"] }; | ||
| } | ||
| function sha256File(filename) { | ||
@@ -366,6 +377,22 @@ return new Promise((resolve, reject) => { | ||
| }); | ||
| const run = spawnSync(binary, args, { cwd: process.cwd(), stdio: "inherit" }); | ||
| const run = spawnSync(binary, args, { | ||
| cwd: process.cwd(), | ||
| stdio: "inherit", | ||
| env: { ...process.env, PRACTICODE_INSTALL_METHOD: "npm" }, | ||
| }); | ||
| if (run.error) { | ||
| throw new Error(`Failed to run cached Practicode binary: ${run.error.message}`); | ||
| } | ||
| if (run.status === NPM_UPDATE_EXIT_CODE) { | ||
| const npmUpdate = npmUpdateCommand(); | ||
| const update = spawnSync(npmUpdate.command, npmUpdate.args, { | ||
| cwd: process.cwd(), | ||
| stdio: "inherit", | ||
| }); | ||
| if (update.error) { | ||
| throw new Error(`Failed to update Practicode with npm: ${update.error.message}`); | ||
| } | ||
| if (update.status === 0) console.error("practicode updated. Run practicode again."); | ||
| return update.status ?? 1; | ||
| } | ||
| return run.status ?? 1; | ||
@@ -380,3 +407,4 @@ } | ||
| main, | ||
| npmUpdateCommand, | ||
| sha256File, | ||
| }; |
+1
-1
@@ -456,3 +456,3 @@ # This file is automatically @generated by Cargo. | ||
| name = "practicode" | ||
| version = "0.2.3" | ||
| version = "0.2.5" | ||
| dependencies = [ | ||
@@ -459,0 +459,0 @@ "anyhow", |
+1
-1
| [package] | ||
| name = "practicode" | ||
| version = "0.2.3" | ||
| version = "0.2.5" | ||
| edition = "2024" | ||
@@ -5,0 +5,0 @@ description = "Offline-first programming language lessons and coding practice in a Rust terminal UI." |
+1
-1
| { | ||
| "name": "practicode", | ||
| "version": "0.2.3", | ||
| "version": "0.2.5", | ||
| "description": "Offline-first programming language lessons and coding practice in a Rust terminal UI.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+2
-0
@@ -210,2 +210,4 @@ # Practicode | ||
| When an update is found, npm installs can apply it from the startup prompt. **Later** keeps the reminder for the current run and asks again on the next launch; **Skip this version** waits until a newer release is available. Cargo and direct binary installs show update commands instead of running an installer automatically. | ||
| ## Local Data And Privacy | ||
@@ -212,0 +214,0 @@ |
@@ -44,2 +44,4 @@ use super::*; | ||
| pub ai_next_command: String, | ||
| #[serde(default, skip_serializing_if = "String::is_empty")] | ||
| pub skipped_update_version: String, | ||
| } | ||
@@ -65,2 +67,3 @@ | ||
| ai_next_command: String::new(), | ||
| skipped_update_version: String::new(), | ||
| } | ||
@@ -67,0 +70,0 @@ } |
+4
-0
@@ -78,2 +78,3 @@ use anyhow::{Context, Result, bail}; | ||
| let result = app.run(&mut terminal); | ||
| let npm_update_requested = app.npm_update_requested(); | ||
| process::terminate_active_commands(); | ||
@@ -86,2 +87,5 @@ ratatui::restore(); | ||
| ); | ||
| if result.is_ok() && npm_update_requested { | ||
| std::process::exit(update::NPM_UPDATE_EXIT_CODE); | ||
| } | ||
| result | ||
@@ -88,0 +92,0 @@ } |
+19
-2
@@ -22,3 +22,3 @@ use crate::{ | ||
| }, | ||
| update::{CURRENT_VERSION, UpdateCheck, check_latest_version}, | ||
| update::{CURRENT_VERSION, UpdateCheck, check_latest_version, update_is_eligible}, | ||
| }; | ||
@@ -40,3 +40,3 @@ use anyhow::{Context, Result}; | ||
| collections::HashMap, | ||
| fs, | ||
| env, fs, | ||
| io::stdout, | ||
@@ -110,2 +110,7 @@ path::PathBuf, | ||
| struct UpdatePrompt { | ||
| version: String, | ||
| cursor: usize, | ||
| } | ||
| fn localized_status(language: &str, status: &str) -> String { | ||
@@ -167,2 +172,6 @@ let key = format!("status_{status}"); | ||
| update_notice: Option<String>, | ||
| update_prompt: Option<UpdatePrompt>, | ||
| update_prompted_version: Option<String>, | ||
| npm_install: bool, | ||
| npm_update_requested: bool, | ||
| last_update_check: Option<Instant>, | ||
@@ -256,2 +265,6 @@ left_area: Rect, | ||
| update_notice: None, | ||
| update_prompt: None, | ||
| update_prompted_version: None, | ||
| npm_install: env::var("PRACTICODE_INSTALL_METHOD").ok().as_deref() == Some("npm"), | ||
| npm_update_requested: false, | ||
| last_update_check: None, | ||
@@ -304,2 +317,6 @@ left_area: Rect::default(), | ||
| pub fn npm_update_requested(&self) -> bool { | ||
| self.npm_update_requested | ||
| } | ||
| pub fn handle_command_for_test(&mut self, value: &str) -> Result<()> { | ||
@@ -306,0 +323,0 @@ self.handle_command(value) |
@@ -488,9 +488,2 @@ use super::*; | ||
| } | ||
| if !self.learning_session.can_judge() { | ||
| self.learn_result = | ||
| ui_text(&self.state.settings.ui_language, "learning_run_gate").to_string(); | ||
| self.learning_session.set_view(LearningView::Result); | ||
| self.show_current_syntax_lesson(); | ||
| return Ok(()); | ||
| } | ||
| self.save_syntax_code()?; | ||
@@ -497,0 +490,0 @@ let lesson = current_syntax_lesson(&self.state, &self.state.settings.language); |
@@ -9,2 +9,5 @@ use super::*; | ||
| } | ||
| if self.update_prompt.is_some() { | ||
| return self.handle_update_prompt_key(key); | ||
| } | ||
| if self.handle_busy_key(key) { | ||
@@ -11,0 +14,0 @@ return Ok(()); |
+0
-33
@@ -148,6 +148,2 @@ use super::*; | ||
| pub(super) fn can_judge(&self) -> bool { | ||
| !self.guided || self.step == LearningStep::Exercise | ||
| } | ||
| pub(super) fn mark_assisted(&mut self) { | ||
@@ -551,31 +547,2 @@ if !self.guided | ||
| #[test] | ||
| fn guided_judge_gate_allows_only_exercise_while_inactive_browsing_can_run() { | ||
| let mut session = LearningSession { | ||
| guided: true, | ||
| queue: vec![LearningItem { | ||
| lesson_id: "lesson", | ||
| review: false, | ||
| }], | ||
| index: 0, | ||
| step: LearningStep::Review, | ||
| view: LearningView::Code, | ||
| assisted: false, | ||
| }; | ||
| for step in [ | ||
| LearningStep::Review, | ||
| LearningStep::Delta, | ||
| LearningStep::Predict, | ||
| LearningStep::Reflect, | ||
| LearningStep::Complete, | ||
| ] { | ||
| session.step = step; | ||
| assert!(!session.can_judge(), "{step:?}"); | ||
| } | ||
| session.step = LearningStep::Exercise; | ||
| assert!(session.can_judge()); | ||
| assert!(LearningSession::inactive().can_judge()); | ||
| } | ||
| #[test] | ||
| fn session_queue_orders_two_due_reviews_then_one_new_core_item() { | ||
@@ -582,0 +549,0 @@ let mut state = state(); |
+35
-11
@@ -16,2 +16,17 @@ use super::*; | ||
| } | ||
| if let Some(update) = self.update_status() { | ||
| let hint = if self.mode == AppMode::Learn { | ||
| self.learning_primary_hint() | ||
| } else { | ||
| ui_text( | ||
| &self.state.settings.ui_language, | ||
| match self.mode { | ||
| AppMode::Home => "hint_home_compact", | ||
| AppMode::Learn => "hint_learn_compact", | ||
| AppMode::Problems => "hint_problem_compact", | ||
| }, | ||
| ) | ||
| }; | ||
| return format!(" {update} | {hint} "); | ||
| } | ||
| if self.mode == AppMode::Learn { | ||
@@ -32,7 +47,6 @@ return format!(" {} ", self.learning_primary_hint()); | ||
| if self.mode == AppMode::Home && !self.show_output { | ||
| return format!( | ||
| " PRACTICODE | {} | {} ", | ||
| ui_text(lang, "mode_home"), | ||
| self.mode_hint() | ||
| ); | ||
| let tail = self | ||
| .update_status() | ||
| .unwrap_or_else(|| self.mode_hint().to_string()); | ||
| return format!(" PRACTICODE | {} | {} ", ui_text(lang, "mode_home"), tail); | ||
| } | ||
@@ -67,4 +81,8 @@ if self.mode == AppMode::Learn { | ||
| }; | ||
| let update = self | ||
| .update_status() | ||
| .map(|status| format!(" | {status}")) | ||
| .unwrap_or_default(); | ||
| return format!( | ||
| " {} | PRACTICODE | {} | {} | {} | {done}/{total}{focus} | {}:{} ", | ||
| " {} | PRACTICODE | {} | {} | {} | {done}/{total}{focus} | {}:{}{update} ", | ||
| primary, | ||
@@ -93,7 +111,4 @@ ui_text(lang, "mode_learn"), | ||
| }; | ||
| let tail = if let Some(version) = self.update_notice.as_ref() { | ||
| format!( | ||
| "{}:{version} /update", | ||
| ui_text(&self.state.settings.ui_language, "update") | ||
| ) | ||
| let tail = if let Some(status) = self.update_status() { | ||
| status | ||
| } else if self.task_rx.is_some() { | ||
@@ -119,2 +134,11 @@ self.mode_hint().to_string() | ||
| fn update_status(&self) -> Option<String> { | ||
| self.update_notice.as_ref().map(|version| { | ||
| format!( | ||
| "{}:{version} /update", | ||
| ui_text(&self.state.settings.ui_language, "update") | ||
| ) | ||
| }) | ||
| } | ||
| pub(super) fn next_source_help(&self) -> String { | ||
@@ -121,0 +145,0 @@ ui_text(&self.state.settings.ui_language, "next_source_help").to_string() |
+269
-6
@@ -153,3 +153,16 @@ use super::*; | ||
| match &result { | ||
| UpdateCheck::Available(version) => self.update_notice = Some(version.clone()), | ||
| UpdateCheck::Available(version) => { | ||
| let skipped = self.state.settings.skipped_update_version == *version; | ||
| self.update_notice = | ||
| (!skipped || showing_update_check).then(|| version.clone()); | ||
| let automatic = update_is_eligible( | ||
| version, | ||
| CURRENT_VERSION, | ||
| &self.state.settings.skipped_update_version, | ||
| ) && self.update_prompted_version.as_deref() != Some(version); | ||
| if showing_update_check || automatic { | ||
| self.open_update_prompt(version.clone()); | ||
| return; | ||
| } | ||
| } | ||
| UpdateCheck::Current | UpdateCheck::Disabled => self.update_notice = None, | ||
@@ -374,7 +387,4 @@ UpdateCheck::Failed => {} | ||
| let lang = self.state.settings.ui_language.clone(); | ||
| if let Some(version) = &self.update_notice { | ||
| self.write_text_output(&format!( | ||
| "{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode", | ||
| ui_text(&lang, "update_available") | ||
| )); | ||
| if let Some(version) = self.update_notice.clone() { | ||
| self.open_update_prompt(version); | ||
| } else if self.update_rx.is_some() { | ||
@@ -391,2 +401,101 @@ self.write_text_output(ui_text(&lang, "update_checking")); | ||
| fn open_update_prompt(&mut self, version: String) { | ||
| self.update_prompted_version = Some(version.clone()); | ||
| self.update_prompt = Some(UpdatePrompt { version, cursor: 0 }); | ||
| self.refresh_update_prompt(); | ||
| } | ||
| fn refresh_update_prompt(&mut self) { | ||
| let Some(prompt) = &self.update_prompt else { | ||
| return; | ||
| }; | ||
| let lang = &self.state.settings.ui_language; | ||
| let option = |index, key| { | ||
| format!( | ||
| "{} {}", | ||
| if prompt.cursor == index { ">" } else { " " }, | ||
| ui_text(lang, key) | ||
| ) | ||
| }; | ||
| let output = format!( | ||
| "{}: practicode {} ({} {CURRENT_VERSION})\n\n{}\n{}\n{}\n\n{}", | ||
| ui_text(lang, "update_available"), | ||
| prompt.version, | ||
| ui_text(lang, "update_current_version"), | ||
| option(0, "update_now"), | ||
| option(1, "update_later"), | ||
| option(2, "update_skip_version"), | ||
| ui_text(lang, "update_prompt_help"), | ||
| ); | ||
| self.write_text_output(&output); | ||
| } | ||
| pub(super) fn handle_update_prompt_key(&mut self, key: KeyEvent) -> Result<()> { | ||
| let Some(prompt) = self.update_prompt.as_mut() else { | ||
| return Ok(()); | ||
| }; | ||
| match key.code { | ||
| KeyCode::Up | KeyCode::Char('k') => { | ||
| prompt.cursor = (prompt.cursor + 2) % 3; | ||
| self.refresh_update_prompt(); | ||
| } | ||
| KeyCode::Down | KeyCode::Char('j') => { | ||
| prompt.cursor = (prompt.cursor + 1) % 3; | ||
| self.refresh_update_prompt(); | ||
| } | ||
| KeyCode::Esc => self.dismiss_update_prompt(), | ||
| KeyCode::Enter => match prompt.cursor { | ||
| 0 if self.npm_install => { | ||
| self.npm_update_requested = true; | ||
| self.should_quit = true; | ||
| self.update_prompt = None; | ||
| } | ||
| 0 => { | ||
| let version = prompt.version.clone(); | ||
| self.update_prompt = None; | ||
| self.show_update_commands(&version); | ||
| } | ||
| 1 => self.dismiss_update_prompt(), | ||
| 2 => { | ||
| let version = prompt.version.clone(); | ||
| self.state.settings.skipped_update_version = version; | ||
| self.update_notice = None; | ||
| save_state(&self.root, &self.state)?; | ||
| self.dismiss_update_prompt(); | ||
| } | ||
| _ => {} | ||
| }, | ||
| _ => {} | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn show_update_commands(&mut self, version: &str) { | ||
| let lang = &self.state.settings.ui_language; | ||
| self.write_text_output(&format!( | ||
| "{}: practicode {version} ({} {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode", | ||
| ui_text(lang, "update_available"), | ||
| ui_text(lang, "update_current_version") | ||
| )); | ||
| } | ||
| fn dismiss_update_prompt(&mut self) { | ||
| self.update_prompt = None; | ||
| match self.mode { | ||
| AppMode::Home => { | ||
| self.output = self.home_text(); | ||
| self.show_output = false; | ||
| self.focus = Focus::Home; | ||
| } | ||
| AppMode::Learn => self.show_current_syntax_lesson(), | ||
| AppMode::Problems => { | ||
| self.show_output = false; | ||
| self.focus = match self.practice_view { | ||
| PracticeView::Problem => Focus::Left, | ||
| PracticeView::Code => Focus::Code, | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| pub(super) fn append_note(&mut self, note: &str) -> Result<()> { | ||
@@ -507,3 +616,157 @@ append_problem_note(&self.root, note)?; | ||
| fn deliver_update(app: &mut PracticodeApp, result: UpdateCheck) { | ||
| let (tx, rx) = std::sync::mpsc::channel(); | ||
| tx.send(result).unwrap(); | ||
| app.update_rx = Some(rx); | ||
| app.check_update(); | ||
| } | ||
| #[test] | ||
| fn automatic_update_prompt_opens_in_home_and_learn() { | ||
| for (name, mode) in [("home", AppMode::Home), ("learn", AppMode::Learn)] { | ||
| let mut app = localized_app(&format!("practicode-update-{name}"), "en"); | ||
| app.mode = mode; | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.9".to_string())); | ||
| assert!(app.update_prompt.is_some(), "{mode:?}"); | ||
| assert!(app.show_output, "{mode:?}"); | ||
| assert!( | ||
| app.output.contains("Update now"), | ||
| "{mode:?}: {}", | ||
| app.output | ||
| ); | ||
| } | ||
| } | ||
| #[test] | ||
| fn automatic_update_failure_or_opt_out_does_not_open_an_overlay() { | ||
| for (name, result) in [ | ||
| ("failed", UpdateCheck::Failed), | ||
| ("disabled", UpdateCheck::Disabled), | ||
| ] { | ||
| let mut app = localized_app(&format!("practicode-update-{name}"), "en"); | ||
| app.mode = AppMode::Learn; | ||
| deliver_update(&mut app, result); | ||
| assert!(app.update_prompt.is_none(), "{name}"); | ||
| assert!(!app.show_output, "{name}"); | ||
| } | ||
| } | ||
| #[test] | ||
| fn available_update_prompts_and_later_reminds_in_every_mode() { | ||
| let mut app = localized_app("practicode-update-prompt", "ko"); | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.9".to_string())); | ||
| assert!(app.output.contains("지금 업데이트"), "{}", app.output); | ||
| assert!(app.output.contains("나중에"), "{}", app.output); | ||
| assert!(app.output.contains("이 버전 건너뛰기"), "{}", app.output); | ||
| app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) | ||
| .unwrap(); | ||
| app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) | ||
| .unwrap(); | ||
| assert!(!app.show_output); | ||
| for mode in [AppMode::Home, AppMode::Learn, AppMode::Problems] { | ||
| app.mode = mode; | ||
| for width in [80, 140] { | ||
| assert!( | ||
| app.status_text_for_width(width).contains("/update"), | ||
| "{mode:?} {width}: {}", | ||
| app.status_text_for_width(width) | ||
| ); | ||
| } | ||
| } | ||
| app.mode = AppMode::Home; | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.9".to_string())); | ||
| assert!(!app.show_output, "same version prompted twice"); | ||
| } | ||
| #[test] | ||
| fn skipped_update_stays_hidden_until_a_newer_version() { | ||
| let mut app = localized_app("practicode-update-skip", "en"); | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.8".to_string())); | ||
| app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) | ||
| .unwrap(); | ||
| app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) | ||
| .unwrap(); | ||
| app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) | ||
| .unwrap(); | ||
| assert_eq!(app.state.settings.skipped_update_version, "9.9.8"); | ||
| assert!(app.update_notice.is_none()); | ||
| assert!(!app.show_output); | ||
| let bank = load_bank(&app.root).unwrap(); | ||
| assert_eq!( | ||
| load_state(&app.root, &bank) | ||
| .unwrap() | ||
| .settings | ||
| .skipped_update_version, | ||
| "9.9.8" | ||
| ); | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.8".to_string())); | ||
| assert!(!app.show_output, "skipped version prompted again"); | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.9".to_string())); | ||
| assert!(app.output.contains("practicode 9.9.9"), "{}", app.output); | ||
| assert!(app.show_output); | ||
| } | ||
| #[test] | ||
| fn explicit_update_check_reopens_a_skipped_version() { | ||
| let mut app = localized_app("practicode-update-skipped-explicit", "en"); | ||
| app.state.settings.skipped_update_version = "9.9.9".to_string(); | ||
| app.write_text_output(ui_text("en", "update_checking")); | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.9".to_string())); | ||
| assert!(app.output.contains("practicode 9.9.9"), "{}", app.output); | ||
| assert!(app.output.contains("Update now"), "{}", app.output); | ||
| assert!(app.show_output); | ||
| } | ||
| #[test] | ||
| fn update_now_hands_an_npm_install_back_to_the_launcher() { | ||
| let mut app = localized_app("practicode-update-now-npm", "en"); | ||
| app.npm_install = true; | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.9".to_string())); | ||
| app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) | ||
| .unwrap(); | ||
| assert!(app.should_quit); | ||
| assert!(app.npm_update_requested()); | ||
| } | ||
| #[test] | ||
| fn update_now_only_shows_commands_for_an_unmarked_install() { | ||
| let mut app = localized_app("practicode-update-now-manual", "en"); | ||
| app.npm_install = false; | ||
| deliver_update(&mut app, UpdateCheck::Available("9.9.9".to_string())); | ||
| app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) | ||
| .unwrap(); | ||
| assert!(!app.should_quit); | ||
| assert!(!app.npm_update_requested); | ||
| assert!( | ||
| app.output.contains("npm update -g practicode"), | ||
| "{}", | ||
| app.output | ||
| ); | ||
| assert!( | ||
| app.output.contains("cargo install --force practicode"), | ||
| "{}", | ||
| app.output | ||
| ); | ||
| } | ||
| #[test] | ||
| fn update_check_refreshes_visible_checking_notice() { | ||
@@ -510,0 +773,0 @@ let root = crate::process::unique_temp_path("practicode-update-test", "dir"); |
+30
-20
@@ -232,2 +232,4 @@ use super::*; | ||
| PROBLEM_NOTES_PATH | ||
| } else if self.update_prompt.is_some() { | ||
| ui_text(&self.state.settings.ui_language, "update") | ||
| } else { | ||
@@ -1145,2 +1147,30 @@ ui_text(&self.state.settings.ui_language, "output") | ||
| #[test] | ||
| fn update_prompt_uses_a_localized_full_body() { | ||
| for (width, height) in [(80, 24), (140, 40)] { | ||
| let mut app = PracticodeApp::new(tmp_root(&format!("update-prompt-{width}"))).unwrap(); | ||
| app.state.settings.ui_language = "ko".to_string(); | ||
| let (tx, rx) = std::sync::mpsc::channel(); | ||
| tx.send(UpdateCheck::Available("9.9.9".to_string())) | ||
| .unwrap(); | ||
| app.update_rx = Some(rx); | ||
| app.check_update(); | ||
| let terminal = draw_at(&mut app, width, height); | ||
| let top = (0..width) | ||
| .map(|x| terminal.backend().buffer()[(x, 0)].symbol()) | ||
| .collect::<String>(); | ||
| let compact_top = top.replace(' ', ""); | ||
| let text = buffer_text(&terminal); | ||
| let compact_text = text.replace(' ', ""); | ||
| assert!(compact_top.contains("업데이트"), "{width}: {top}"); | ||
| assert!(!compact_top.contains("결과"), "{width}: {top}"); | ||
| assert!(compact_text.contains("지금업데이트"), "{width}: {text}"); | ||
| assert_eq!(app.output_area, Rect::new(0, 0, width, height - 2)); | ||
| assert_eq!(app.left_area, Rect::default()); | ||
| assert_eq!(app.code_area, Rect::default()); | ||
| } | ||
| } | ||
| #[test] | ||
| fn narrow_learn_result_uses_the_full_body() { | ||
@@ -1165,22 +1195,2 @@ let mut app = PracticodeApp::new(tmp_root("learn-result-split")).unwrap(); | ||
| #[test] | ||
| fn learning_gate_selects_visible_result_at_narrow_and_wide_widths() { | ||
| for (width, height) in [(80, 24), (100, 30)] { | ||
| let mut app = PracticodeApp::new(tmp_root(&format!("gate-visible-{width}"))).unwrap(); | ||
| app.handle_command("learn python").unwrap(); | ||
| app.handle_command("run").unwrap(); | ||
| let terminal = draw_at(&mut app, width, height); | ||
| let text = buffer_text(&terminal); | ||
| assert_eq!(app.learning_session.view(), LearningView::Result); | ||
| assert!( | ||
| text.contains("Next: use /next until Exercise"), | ||
| "{width}: {text}" | ||
| ); | ||
| assert!(app.output_area.width > 0, "{width}"); | ||
| assert_eq!(app.code_area, Rect::default(), "{width}"); | ||
| } | ||
| } | ||
| #[test] | ||
| fn manual_judge_selects_visible_result_at_narrow_and_wide_widths() { | ||
@@ -1187,0 +1197,0 @@ if which("python3").or_else(|| which("python")).is_none() { |
+5
-0
@@ -5,2 +5,3 @@ use crate::process::{run_capture, which}; | ||
| pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); | ||
| pub const NPM_UPDATE_EXIT_CODE: i32 = 42; | ||
@@ -44,2 +45,6 @@ #[derive(Clone, Debug, Eq, PartialEq)] | ||
| pub fn update_is_eligible(latest: &str, current: &str, skipped: &str) -> bool { | ||
| latest != skipped && is_newer(latest, current) | ||
| } | ||
| fn version_parts(version: &str) -> Option<[u64; 3]> { | ||
@@ -46,0 +51,0 @@ let mut parts = version.split('.'); |
1775928
0.41%16309
0.29%247
0.82%12
20%