From abf636db833b7660081043e43bee660ef606f631 Mon Sep 17 00:00:00 2001 From: Franz Hemmer Date: Thu, 5 Nov 2026 19:35:42 -0500 Subject: [PATCH 1/2] feat: view and edit a project's risk level in Manage > Projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pmd gained a 0-10 risk level per project (project-manager#104). The Projects dialog now lists it in a Risk column and edits it with a 0-10 chooser beside priority. The value rides the existing full-row PUT with If-Match, so the "Changed elsewhere" flow shows a risk difference like any other field, and pmd's 400 ("riskLevel must be an integer 0-10") lands on the field. A row without riskLevel (a pmd that predates it) reads as unset, shows as "—", and saves without the field, since pmd refuses null and an older pmd refuses the unknown key. A new project leaves it to pmd, which starts at 5. Co-Authored-By: Claude Opus 5.5 --- crates/dashboard-app/src/captures.rs | 2 + crates/dashboard-app/src/manage/projects.rs | 63 ++++++++- crates/dashboard-app/src/projects_tests.rs | 139 ++++++++++++++++++-- crates/dashboard-core/src/projects.rs | 61 +++++++-- crates/dashboard-core/tests/projects.rs | 78 +++++++++++ 5 files changed, 318 insertions(+), 25 deletions(-) diff --git a/crates/dashboard-app/src/captures.rs b/crates/dashboard-app/src/captures.rs index ddce022..4a43c16 100644 --- a/crates/dashboard-app/src/captures.rs +++ b/crates/dashboard-app/src/captures.rs @@ -576,6 +576,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. 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 Date: Thu, 5 Nov 2026 20:19:16 -0500 Subject: [PATCH 2/2] test: capture a risk level changed elsewhere in a taller window The existing conflict capture at 760 px leaves the panel below the fold, so the risk difference had no screenshot. This adds projects-risk-conflict at 1000 px on gh-x, which has a checkout and so no Link checkout box. Co-Authored-By: Claude Opus 5.5 --- crates/dashboard-app/src/captures.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/dashboard-app/src/captures.rs b/crates/dashboard-app/src/captures.rs index 4a43c16..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(); @@ -606,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;