diff --git a/crates/dashboard-app/src/captures.rs b/crates/dashboard-app/src/captures.rs index ddce022..c1d844a 100644 --- a/crates/dashboard-app/src/captures.rs +++ b/crates/dashboard-app/src/captures.rs @@ -518,13 +518,13 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) { use dashboard_core::write::WriteError; let name = |state: &str| format!("projects-{state}{suffix}"); let folder = Folder::new(); - let open_dialog = |cx: &mut HeadlessAppContext, scopes: &[&str], folder: &Folder| { + let open_dialog_sized = |cx: &mut HeadlessAppContext, scopes: &[&str], folder: &Folder, height: f32| { let backend = FakeProjects::new(scopes); let (handle, view) = open_with( cx, demo::checkouts(), 1100., - 760., + height, Options { write: backend.clone(), config_path: Some(folder.config_dir().join("config.toml")), @@ -548,6 +548,8 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) { settle(cx); (handle, manage, backend) }; + let open_dialog = + |cx: &mut HeadlessAppContext, scopes: &[&str], folder: &Folder| open_dialog_sized(cx, scopes, folder, 760.); let click = |cx: &mut HeadlessAppContext, handle: AnyWindowHandle, id: &str| { let id = gpui_kit::SharedString::from(id.to_owned()); cx.update_window(handle, |_, window, cx| window.click(id, cx)).unwrap(); @@ -576,6 +578,8 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) { click(cx, handle, "project-row-2"); click(cx, handle, "project-priority-0"); save(cx, handle, out, &name("edit-priority")); + click(cx, handle, "project-risk-8"); + save(cx, handle, out, &name("edit-risk")); let fork = folder.checkout("fork", "https://github.com/someone/hs-buddy.git"); set(cx, handle, &manage, "folder", &fork.to_string_lossy()); // The button can sit below the fold at 150%; press it directly. @@ -604,6 +608,19 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) { let (read_only, _, _) = open_dialog(cx, &["read", "write:work"], &folder); save(cx, read_only, out, &name("read-only")); + // A risk level changed elsewhere, in a window tall enough to show the whole conflict panel. gh-x has a checkout, + // so no Link checkout box takes the room. + let (tall, _, tall_backend) = open_dialog_sized(cx, &["read", "write:projects"], &folder, 1000.); + click(cx, tall, "project-row-3"); + click(cx, tall, "project-risk-8"); + tall_backend.fail_next(WriteError::StaleVersion, |rows| { + let row = rows.iter_mut().find(|row| row["slug"] == "gh-x").unwrap(); + row["riskLevel"] = serde_json::json!(6); + row["version"] = serde_json::json!(5); + }); + key(cx, tall, &["ctrl-enter"]); + save(cx, tall, out, &name("risk-conflict")); + // Frames for the recording, at 100% only: keyboard alone, adding a project and setting its priority. if !suffix.is_empty() { return; diff --git a/crates/dashboard-app/src/manage/projects.rs b/crates/dashboard-app/src/manage/projects.rs index 87da39f..e6d0f04 100644 --- a/crates/dashboard-app/src/manage/projects.rs +++ b/crates/dashboard-app/src/manage/projects.rs @@ -1,5 +1,5 @@ //! The Projects dialog (#34): every pmd project, sorted by priority, with an editor that tracks a new repo, sets -//! P0–P5 or no tier, edits goal, notes and requires, untracks or re-tracks a project, and links a local checkout so +//! P0–P5 or no tier and a risk level from 0 to 10, edits goal, notes and requires, untracks or re-tracks a project, and links a local checkout so //! the dashboard shows it. //! //! pmd's projects table is the source of truth for priority (option C of project-manager#50), so there is no reset to @@ -53,7 +53,9 @@ fn upsert(projects: &mut ProjectsDialog, project: Project) { /// The priority choices: P0 to P5, then no tier (empty). const PRIORITY_CHOICES: [&str; 7] = ["0", "1", "2", "3", "4", "5", ""]; -/// The fields typed as text; priority and tracked are chosen. +/// The risk level choices, 0 to 10. There is no "unset": a row without one keeps it unset until one is picked. +const RISK_CHOICES: [&str; 11] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]; +/// The fields typed as text; priority, risk level and tracked are chosen. const TEXT_FIELDS: [&str; 5] = ["repo", "slug", "goal", "notes", "requires"]; pub struct ProjectsDialog { @@ -81,6 +83,7 @@ pub struct ProjectEditor { form: Form, inputs: Vec<(&'static str, Entity)>, priorities: Vec, + risks: Vec, folder: Entity, link: Control, /// A checkout link's result: the message, and whether it is an error. @@ -394,6 +397,7 @@ impl Manage { form, inputs, priorities: controls(PRIORITY_CHOICES.len(), cx), + risks: controls(RISK_CHOICES.len(), cx), folder, link: control(cx), link_result: None, @@ -458,6 +462,19 @@ impl Manage { cx.notify(); } + fn set_project_risk(&mut self, risk: &str, cx: &mut Context) { + let writable = self.projects_writable(); + let Some(editor) = self.open_projects().and_then(|(_, projects)| projects.editor.as_mut()) else { + return; + }; + if editor.locked(writable) { + return; + } + // Like priority, picking only edits the form. + editor.form.set("riskLevel", risk); + cx.notify(); + } + /// Ctrl+Enter or Save: checks the project, then one `PUT` of all of it. pub fn save_project(&mut self, window: &mut Window, cx: &mut Context) { self.put_project(None, window, cx); @@ -834,6 +851,7 @@ impl Manage { .child(div().w(rems(10.)).flex_none().child("Slug")) .child(div().flex_1().min_w_0().child("Repository")) .child(div().w(rems(5.)).flex_none().child("Priority")) + .child(div().w(rems(4.)).flex_none().child("Risk")) .child(div().w(rems(6.)).flex_none().child("Tracked")) .child(div().w(rems(6.)).flex_none().child("Checkout")), ); @@ -856,10 +874,11 @@ impl Manage { .role(Role::ListBoxOption) .aria_selected(selected) .aria_label(format!( - "{}, {}, {}, {}, checkout {}", + "{}, {}, {}, risk {}, {}, checkout {}", project.slug, project.repo, project.priority_label(), + project.risk_label(), if project.tracked { "tracked" } else { "untracked" }, linked )) @@ -882,6 +901,7 @@ impl Manage { .child(div().w(rems(10.)).flex_none().truncate().child(project.slug.clone())) .child(div().flex_1().min_w_0().truncate().child(project.repo.clone())) .child(div().w(rems(5.)).flex_none().child(project.priority_label())) + .child(div().w(rems(4.)).flex_none().child(project.risk_label())) .child( div() .w(rems(6.)) @@ -1071,6 +1091,43 @@ impl Manage { }), ) .into_any_element() + } else if field == "riskLevel" { + v_flex() + .gap_1() + .child( + h_flex() + .gap_1() + .flex_wrap() + .children(RISK_CHOICES.iter().zip(&editor.risks).map(|(risk, control)| { + let risk = *risk; + chip( + format!("project-risk-{risk}"), + risk.to_owned(), + &control.focus, + form.value("riskLevel") == risk, + locked, + move |manage, _, cx| manage.set_project_risk(risk, cx), + cx, + ) + })), + ) + .when(form.value("riskLevel").is_empty(), |this| { + let text = if creating { + "Not set: pmd starts a new project at 5." + } else { + "Not set in pmd. Pick one to set it." + }; + this.child( + div() + .id("project-risk-unset") + .test_support() + .aria_label(text) + .text_xs() + .text_color(theme.muted_foreground) + .child(text), + ) + }) + .into_any_element() } else if field == "tracked" { div() .id("project-tracked") diff --git a/crates/dashboard-app/src/projects_tests.rs b/crates/dashboard-app/src/projects_tests.rs index f0f09d1..d65d76a 100644 --- a/crates/dashboard-app/src/projects_tests.rs +++ b/crates/dashboard-app/src/projects_tests.rs @@ -21,17 +21,29 @@ fn project(slug: &str, priority: Option, tracked: bool, goal: &str) -> Value "requires": [], "tracked": tracked, "source": "portfolio", "version": 2}) } -/// A believable portfolio: tiers P0 to P4, one without a tier, one untracked P5. +/// A believable portfolio: tiers P0 to P4, one without a tier, one untracked P5, each with a risk level. pub(crate) fn sample_projects() -> Vec { - vec![ - project("set-it-free-loop", Some(1), true, "Ship the pause control"), - project("gh-x", Some(3), true, "Maintenance: keep healthy, no new initiatives."), - project("chief", None, true, "Run the fleet"), - project("project-dashboard", Some(0), true, "Manage pmd from the dashboard"), - project("hs-buddy", Some(2), true, "Sign the Windows installer"), - project("weather-cli", Some(5), false, "Dormant"), - project("tickdown", Some(4), false, "Paused for now"), + [ + (project("set-it-free-loop", Some(1), true, "Ship the pause control"), 6), + ( + project("gh-x", Some(3), true, "Maintenance: keep healthy, no new initiatives."), + 4, + ), + (project("chief", None, true, "Run the fleet"), 9), + ( + project("project-dashboard", Some(0), true, "Manage pmd from the dashboard"), + 3, + ), + (project("hs-buddy", Some(2), true, "Sign the Windows installer"), 7), + (project("weather-cli", Some(5), false, "Dormant"), 2), + (project("tickdown", Some(4), false, "Paused for now"), 2), ] + .into_iter() + .map(|(mut row, risk)| { + row["riskLevel"] = json!(risk); + row + }) + .collect() } type Change = Box) + Send>; @@ -113,6 +125,10 @@ impl WriteBackend for FakeProjects { row["slug"] = json!(slug); row["source"] = json!("api"); row["version"] = json!(1); + // pmd starts a new project at risk 5 unless the body sets one. + if row.get("riskLevel").is_none() { + row["riskLevel"] = json!(5); + } rows.push(row.clone()); Ok(row) } @@ -299,15 +315,17 @@ async fn the_list_sorts_by_priority_hides_untracked_and_shows_which_have_a_check ); assert_eq!( text_of(cx, handle, "project-row-3"), - "gh-x, hemsoft-dev/gh-x, P3, tracked, checkout Linked" + "gh-x, hemsoft-dev/gh-x, P3, risk 4/10, tracked, checkout Linked" ); assert_eq!( text_of(cx, handle, "project-row-4"), - "chief, hemsoft-dev/chief, —, tracked, checkout —" + "chief, hemsoft-dev/chief, —, risk 9/10, tracked, checkout —" ); click(cx, handle, "projects-show-untracked"); assert_eq!(visible(cx, &manage).len(), 7); - assert!(text_of(cx, handle, "project-row-4").starts_with("tickdown, hemsoft-dev/tickdown, P4, untracked")); + assert!( + text_of(cx, handle, "project-row-4").starts_with("tickdown, hemsoft-dev/tickdown, P4, risk 2/10, untracked") + ); set_input(cx, handle, &manage, "filter", "PAUSE"); assert_eq!( visible(cx, &manage), @@ -353,7 +371,7 @@ async fn a_priority_pick_writes_only_on_save_and_the_table_hears_of_it(cx: &mut assert_eq!( writes[0].body, json!({"repo": "hemsoft-dev/gh-x", "priority": 0, "goal": "Maintenance: keep healthy, no new initiatives.", - "notes": "", "requires": [], "tracked": true}) + "notes": "", "requires": [], "tracked": true, "riskLevel": 4}) ); assert_eq!(wrote.get(), 1, "the table reloads its priorities"); // No tier is null. @@ -479,6 +497,101 @@ async fn changed_elsewhere_shows_both_values_and_reapplies_on_the_new_version(cx assert_eq!(backend.row("gh-x")["priority"], json!(1)); } +#[gpui_kit::test] +async fn a_risk_level_pick_writes_only_on_save_and_the_list_shows_it(cx: &mut TestAppContext) { + let folder = Folder::new(); + let backend = FakeProjects::new(&["read", "write:projects"]); + let (handle, view, manage) = open_with(cx, backend.clone(), &folder); + open_dialog(cx, handle, &view).await; + edit(cx, handle, &manage, "gh-x"); + assert_eq!(form_value(cx, &manage, "riskLevel"), "4"); + assert!(!exists(cx, handle, "project-risk-unset")); + click(cx, handle, "project-risk-10"); + assert_eq!(form_value(cx, &manage, "riskLevel"), "10"); + assert!( + backend.writes.lock().unwrap().is_empty(), + "picking alone writes nothing" + ); + press(cx, handle, "ctrl-enter"); + settle(cx, handle, &view).await; + let writes = backend.writes.lock().unwrap().clone(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].precondition, Precondition::IfMatch(2)); + assert_eq!(writes[0].body["riskLevel"], json!(10)); + assert_eq!( + writes[0].body["priority"], + json!(3), + "the rest of the row goes as stored" + ); + assert_eq!(backend.row("gh-x")["riskLevel"], json!(10)); + click(cx, handle, "projects-back"); + let ix = visible(cx, &manage).iter().position(|s| s == "gh-x").unwrap(); + assert_eq!( + text_of(cx, handle, &format!("project-row-{ix}")), + "gh-x, hemsoft-dev/gh-x, P3, risk 10/10, tracked, checkout Linked" + ); +} + +#[gpui_kit::test] +async fn a_risk_level_changed_elsewhere_shows_both_values_and_reapplies(cx: &mut TestAppContext) { + let folder = Folder::new(); + let backend = FakeProjects::new(&["read", "write:projects"]); + let (handle, view, manage) = open_with(cx, backend.clone(), &folder); + open_dialog(cx, handle, &view).await; + edit(cx, handle, &manage, "gh-x"); + click(cx, handle, "project-risk-8"); + backend.fail_next(WriteError::StaleVersion, |rows| { + let row = rows.iter_mut().find(|row| row["slug"] == "gh-x").unwrap(); + row["riskLevel"] = json!(6); + row["version"] = json!(6); + }); + click(cx, handle, "project-save"); + settle(cx, handle, &view).await; + frame(cx, handle); + assert_eq!(text_of(cx, handle, "conflict-riskLevel"), "Risk level: yours 8, now 6"); + assert!(!exists(cx, handle, "conflict-priority"), "only what differs"); + click(cx, handle, "conflict-reapply"); + settle(cx, handle, &view).await; + assert_eq!(backend.writes.lock().unwrap()[1].precondition, Precondition::IfMatch(6)); + assert_eq!(backend.row("gh-x")["riskLevel"], json!(8)); +} + +#[gpui_kit::test] +async fn a_project_without_a_risk_level_saves_without_one(cx: &mut TestAppContext) { + let folder = Folder::new(); + let backend = FakeProjects::new(&["read", "write:projects"]); + // As an older pmd lists it: no riskLevel at all. + for row in backend.rows.lock().unwrap().iter_mut() { + row.as_object_mut().unwrap().remove("riskLevel"); + } + let (handle, view, manage) = open_with(cx, backend.clone(), &folder); + open_dialog(cx, handle, &view).await; + assert_eq!( + text_of(cx, handle, "project-row-3"), + "gh-x, hemsoft-dev/gh-x, P3, risk —, tracked, checkout Linked" + ); + edit(cx, handle, &manage, "gh-x"); + assert_eq!(form_value(cx, &manage, "riskLevel"), ""); + assert_eq!( + text_of(cx, handle, "project-risk-unset"), + "Not set in pmd. Pick one to set it." + ); + click(cx, handle, "project-priority-1"); + press(cx, handle, "ctrl-enter"); + settle(cx, handle, &view).await; + let writes = backend.writes.lock().unwrap().clone(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].body["priority"], json!(1)); + assert!(writes[0].body.get("riskLevel").is_none(), "no bogus risk level is sent"); + // A new project leaves it to pmd too. + click(cx, handle, "projects-back"); + click(cx, handle, "projects-add"); + assert_eq!( + text_of(cx, handle, "project-risk-unset"), + "Not set: pmd starts a new project at 5." + ); +} + fn config_text(folder: &Folder) -> String { std::fs::read_to_string(folder.config_dir().join("config.toml")).unwrap() } diff --git a/crates/dashboard-core/src/projects.rs b/crates/dashboard-core/src/projects.rs index 7cb047a..c4259b4 100644 --- a/crates/dashboard-core/src/projects.rs +++ b/crates/dashboard-core/src/projects.rs @@ -4,7 +4,8 @@ //! pmd's `projects` table is the source of truth for project priority (Franz, 2026-10-26, option C of //! [project-manager#50](https://github.com/hemsoft-dev/project-manager/issues/50)): a `PUT` sets it and PORTFOLIO.md //! is a view generated from pmd, so there is no override to reset. No tier is `priority: null`, and P5 (dormant) must -//! be untracked. A project is never deleted; untracking keeps its data. See pmd's +//! be untracked. A risk level, 0 to 10, is optional in a `PUT`: left out, pmd keeps the stored one (5 for a new +//! project). A project is never deleted; untracking keeps its data. See pmd's //! [Project writes](https://github.com/hemsoft-dev/project-manager#project-writes). use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -18,16 +19,27 @@ use crate::work::FieldProblem; use crate::write::WriteError; /// The editor's fields, as pmd names them, so a `400` naming one lands next to it. -pub const FIELDS: [&str; 7] = ["slug", "repo", "priority", "goal", "notes", "requires", "tracked"]; +pub const FIELDS: [&str; 8] = [ + "slug", + "repo", + "priority", + "riskLevel", + "goal", + "notes", + "requires", + "tracked", +]; /// pmd's limits. pub const MAX_TEXT: usize = 1000; pub const MAX_REQUIRES: usize = 32; +pub const MAX_RISK: u8 = 10; pub fn label(field: &str) -> &'static str { match field { "slug" => "Slug", "repo" => "Repository", "priority" => "Priority", + "riskLevel" => "Risk level", "goal" => "Goal", "notes" => "Notes", "requires" => "Requires", @@ -44,6 +56,8 @@ pub struct Project { pub repo: String, /// P0 to P5, or `None` for no tier. pub priority: Option, + /// How bad a failure in the project would be, 0 to 10 (project-manager#104), or `None` from a pmd that predates it. + pub risk_level: Option, pub goal: String, pub notes: String, pub requires: Vec, @@ -66,10 +80,21 @@ impl Project { .ok() .filter(|p: &u8| *p <= 5), }; + // Likewise a risk level outside 0–10: it reads as unset, and a save leaves pmd's value alone. + let risk_level = match value.get("riskLevel") { + None | Some(Value::Null) => None, + Some(number) => number + .as_u64() + .ok_or(WriteError::Malformed)? + .try_into() + .ok() + .filter(|r: &u8| *r <= MAX_RISK), + }; Ok(Self { slug: text("slug").ok_or(WriteError::Malformed)?, repo: text("repo").ok_or(WriteError::Malformed)?, priority, + risk_level, goal: text("goal").unwrap_or_default(), notes: text("notes").unwrap_or_default(), requires: value @@ -109,12 +134,18 @@ impl Project { self.priority.map_or("—".to_owned(), |p| format!("P{p}")) } + /// `0/10` to `10/10`, or `—` when pmd gave none. + pub fn risk_label(&self) -> String { + self.risk_level.map_or("—".to_owned(), |r| format!("{r}/{MAX_RISK}")) + } + /// The editor's starting values. pub fn row(&self) -> Row { let values = [ ("slug", self.slug.clone()), ("repo", self.repo.clone()), ("priority", self.priority.map(|p| p.to_string()).unwrap_or_default()), + ("riskLevel", self.risk_level.map(|r| r.to_string()).unwrap_or_default()), ("goal", self.goal.clone()), ("notes", self.notes.clone()), ("requires", self.requires.join(", ")), @@ -221,6 +252,17 @@ pub fn body(values: &BTreeMap) -> Result<(String, Value), Vec None, + text => match text.parse::() { + Ok(n) if n <= MAX_RISK => Some(n), + _ => { + problems.push(("riskLevel", format!("0 to {MAX_RISK}."))); + None + } + }, + }; let tracked = get("tracked") != "false"; if tracked && priority == json!(5) { problems.push(( @@ -248,13 +290,14 @@ pub fn body(values: &BTreeMap) -> Result<(String, Value), Vec