🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

practicode

Package Overview
Dependencies
Maintainers
1
Versions
27
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

practicode - npm Package Compare versions

Comparing version
0.2.3
to
0.2.5
+5
-1
assets/i18n/en.json

@@ -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": "语言",

+46
-39

@@ -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">┌&gt; 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">&gt; 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">┌&gt; Lesson────────────────────────────────────────┐┌Code · Exercise.rs──────────────────────────────────────────────────┐</text>
<text x="16" y="66">│Language delta: Make stdout byte-exact ││&gt; 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: &amp;str, score: &amp;str) -&gt; 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!(&quot;{name} {score}&quot;) │</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(&amp;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!(&quot;{}&quot;, 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">&gt; 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">&gt; 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>

@@ -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,
};

@@ -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",

[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."

{
"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",

@@ -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 @@ }

@@ -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 @@ }

@@ -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(());

@@ -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();

@@ -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()

@@ -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");

@@ -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,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('.');