Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **The project picker offers only projects that can take the work, weightiest
first.** It used to list every project alphabetically, archived ones included
— and an archived project refuses new tasks, so picking one could only fail,
which in the `$EDITOR` and planning flows it did only after you had written
the task out. Archived projects are now dropped from the picker entirely
(unarchiving stays where it was, on the projects screen), and the rest are
ordered by the weight you set every morning, each row showing it, so the
project you are actually working on is the one under the cursor. A parked
project is still offered and simply sorts last. One live project beside
archived ones now skips the picker and creates straight into it, and with
every project archived `n`/`N` say so instead of raising an empty list.
- **The key line stops offering `o` and `g` on a review task with nothing to
show.** A task whose whole product is its summary — an investigation, a
triage, an audit — reaches `review` having never made a branch, and both keys
Expand Down
1 change: 1 addition & 0 deletions crates/voro-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body}
pub use model::{
Dep, DepKind, DepRef, Doc, Event, LivenessSource, NextAction, Priority, Project, RefineOutcome,
Repo, RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url,
projects_for_new_task,
};
pub use pr::{Mergeability, PrPlan, PrRef, format_review_feedback, parse_mergeable, plan_pr};
pub use review::{
Expand Down
53 changes: 53 additions & 0 deletions crates/voro-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,20 @@ pub struct Project {
pub archived: bool,
}

/// The projects a new task can be created in, in the order to offer them
/// (DESIGN.md §9). Archived projects are dropped — `Store::create_task` refuses
/// them (§5), so offering one is offering a choice that can only fail, and in
/// the `$EDITOR` and planning flows it fails only after the operator has
/// written the task out. The rest sort by weight descending, which is the one
/// per-project priority Voro holds (§7), with name ascending inside a weight so
/// the order is stable. Weight 0 is a snooze rather than a retirement, so a
/// parked project stays offered and sorts last.
pub fn projects_for_new_task(projects: &[Project]) -> Vec<&Project> {
let mut offered: Vec<&Project> = projects.iter().filter(|p| !p.archived).collect();
offered.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.name.cmp(&b.name)));
offered
}

/// A checkout a project's work runs in (DESIGN.md §3): the execution target
/// dispatch, `pr`/`open`, worktree cleanup, and `import` resolve against. A
/// project owns at least one, exactly one of which is its default.
Expand Down Expand Up @@ -653,6 +667,45 @@ mod tests {
assert_eq!(format!("{:>6}", Priority::P2), " P2");
}

fn project(name: &str, weight: i64, archived: bool) -> Project {
Project {
id: 1,
name: name.into(),
weight,
viewer: None,
archived,
}
}

#[test]
fn new_task_projects_drop_the_archived_at_any_weight() {
let projects = [
project("live", 1, false),
project("retired-heavy", 5, true),
project("retired-parked", 0, true),
];
let offered = projects_for_new_task(&projects);
assert_eq!(
offered.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["live"]
);
}

#[test]
fn new_task_projects_sort_by_weight_then_name() {
let projects = [
project("beta", 3, false),
project("parked", 0, false),
project("alpha", 3, false),
project("heaviest", 5, false),
];
let offered = projects_for_new_task(&projects);
assert_eq!(
offered.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["heaviest", "alpha", "beta", "parked"]
);
}

fn task_in(state: TaskState, pr_url: Option<&str>, human: bool) -> Task {
Task {
id: 1,
Expand Down
119 changes: 108 additions & 11 deletions crates/voro/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use crate::ui::Hit;
use voro_core::{
Action, ActionRow, AgentsConfig, CompletionReport, DepKind, DepRef, DigestRow, Event,
LivenessSource, PrRef, Priority, Project, Queue, QueueRow, RefineOutcome, RunningRow,
ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, scheduler,
ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, projects_for_new_task,
scheduler,
};

/// Lines `PgDn`/`PgUp` move the focus card in one press. A fixed step, since
Expand All @@ -16,6 +17,12 @@ const DETAIL_PAGE_STEP: i64 = 10;
/// bound on, so this is a defensive path rather than one they can reach.
pub const NO_PROJECTS_HINT: &str = "no projects yet — press tab to Projects, then a to add one";

/// `n`'s refusal when every registered project is archived. An archived project
/// refuses new work (DESIGN.md §5), so there is nothing to pick; unarchiving is
/// the projects screen's job, which is where this points.
pub const ALL_PROJECTS_ARCHIVED_HINT: &str =
"every project is archived — press tab to Projects, then A to unarchive one";

/// How a gated screen jump refuses (DESIGN.md §9). It names `alt-3` rather than
/// `tab` because the jump is the shortest route from either screen the gate
/// leaves reachable, and it lands on Projects from both.
Expand Down Expand Up @@ -1840,14 +1847,24 @@ impl App {
.is_some_and(|t| t.state == TaskState::Refining)
}

/// Begin creating a task in one of the two flows (DESIGN.md §9): straight
/// into it when there is exactly one project, via the project picker when
/// there are several, and a pointer to the projects screen when there are
/// none.
/// The projects the create flows offer, in the order they offer them
/// (DESIGN.md §9) — unarchived only, weightiest first. Both the picker's
/// key handler and its draw arm index this, so `sel` means the same thing
/// to each.
pub fn creatable_projects(&self) -> Vec<&Project> {
projects_for_new_task(&self.projects)
}

/// Begin creating a task in one of the three flows (DESIGN.md §9): straight
/// into it when exactly one project can take work, via the project picker
/// when several can, and a pointer to the projects screen when none can —
/// because none is registered, or because every one of them is archived.
fn new_task(&mut self, flow: CreateFlow) {
match self.projects.len() {
0 => self.status = Some(NO_PROJECTS_HINT.into()),
1 => self.start_create(self.projects[0].id, flow),
let offered: Vec<i64> = self.creatable_projects().iter().map(|p| p.id).collect();
match offered.len() {
0 if self.projects.is_empty() => self.status = Some(NO_PROJECTS_HINT.into()),
0 => self.status = Some(ALL_PROJECTS_ARCHIVED_HINT.into()),
1 => self.start_create(offered[0], flow),
_ => self.mode = Mode::PickProject { sel: 0, flow },
}
}
Expand Down Expand Up @@ -3538,12 +3555,12 @@ impl App {
match key.code {
KeyCode::Esc => return,
KeyCode::Char('j') | KeyCode::Down => {
sel = (sel + 1).min(self.projects.len().saturating_sub(1));
sel = (sel + 1).min(self.creatable_projects().len().saturating_sub(1));
}
KeyCode::Char('k') | KeyCode::Up => sel = sel.saturating_sub(1),
KeyCode::Enter => {
if let Some(project) = self.projects.get(sel) {
self.start_create(project.id, flow);
if let Some(project_id) = self.creatable_projects().get(sel).map(|p| p.id) {
self.start_create(project_id, flow);
}
return;
}
Expand Down Expand Up @@ -7411,6 +7428,86 @@ mod tests {
assert!(written_prompts(&app).is_empty(), "nothing spawns yet");
}

/// An archived project cannot take a new task at all (DESIGN.md §5), so it
/// is not a candidate: with one live project beside three archived ones
/// there is nothing to pick between, and `n` opens the create flow on the
/// live one rather than a picker whose other rows can only fail.
#[test]
fn create_skips_the_picker_when_only_one_project_is_unarchived() {
let mut app = app_with_stub_dispatch();
let live = app.projects[0].id;
for name in ["retired-a", "retired-b", "retired-c"] {
let p = app
.store
.create_project(name, &format!("/tmp/{name}"))
.unwrap();
app.store.set_archived(p.id, true).unwrap();
}
app.refresh().unwrap();
assert_eq!(app.projects.len(), 4);

key(&mut app, KeyCode::Char('n'));
match &app.mode {
Mode::QuickCreate { project_id, .. } => assert_eq!(*project_id, live),
_ => panic!("n should open the quick-create modal"),
}
}

/// With every project archived there is no project to create in, and no
/// picker to open over none. It refuses the way the neighbouring keys do —
/// a no-op with an explanation, pointing at the screen that unarchives.
#[test]
fn create_with_every_project_archived_explains_itself() {
let mut app = app_with_stub_dispatch();
let only = app.projects[0].id;
app.store.set_archived(only, true).unwrap();
app.refresh().unwrap();

for press in ['n', 'N'] {
app.status = None;
key(&mut app, KeyCode::Char(press));
assert!(matches!(app.mode, Mode::Normal), "no picker opens");
assert_eq!(app.status.as_deref(), Some(ALL_PROJECTS_ARCHIVED_HINT));
assert!(app.pending_editor.is_none());
assert!(app.pending_plan.is_none());
}
}

/// The picker offers what can take work in the order the operator ranked it
/// — weight descending, name ascending inside a weight (DESIGN.md §9) — so
/// ⏎ on a row starts the create flow on the project *that* order puts
/// there, not the one alphabetical order would have.
#[test]
fn picker_rows_follow_weight_then_name() {
let mut app = app_with_stub_dispatch();
let demo = app.projects[0].id;
app.store.set_weight(demo, 1).unwrap();
let heavy = app.store.create_project("zeta", "/tmp/zeta").unwrap();
app.store.set_weight(heavy.id, 4).unwrap();
let parked = app.store.create_project("alpha", "/tmp/alpha").unwrap();
app.store.set_weight(parked.id, 0).unwrap();
let hidden = app.store.create_project("beta", "/tmp/beta").unwrap();
app.store.set_weight(hidden.id, 5).unwrap();
app.store.set_archived(hidden.id, true).unwrap();
app.refresh().unwrap();

// zeta (4), demo (1), alpha (0) — beta is archived and absent despite
// outweighing all three.
for (row, expected) in [(0, heavy.id), (1, demo), (2, parked.id)] {
key(&mut app, KeyCode::Char('n'));
assert!(matches!(app.mode, Mode::PickProject { sel: 0, .. }));
for _ in 0..row {
key(&mut app, KeyCode::Char('j'));
}
key(&mut app, KeyCode::Enter);
match &app.mode {
Mode::QuickCreate { project_id, .. } => assert_eq!(*project_id, expected, "{row}"),
_ => panic!("row {row} should open the modal"),
}
key(&mut app, KeyCode::Esc);
}
}

/// `ctrl-n` keeps the manual `$EDITOR` form, the only path that sets state,
/// priority and dependencies at creation time (DESIGN.md §8).
#[test]
Expand Down
41 changes: 38 additions & 3 deletions crates/voro/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,17 @@ fn draw_mode(frame: &mut Frame, app: &App, hits: &mut HitMap) {
frame.render_widget(para, area);
}
Mode::PickProject { sel, flow } => {
// Weight first, then name, as the projects screen lists them
// — the order is weightiest-first (DESIGN.md §9), which reads as
// arbitrary unless the number it sorts on is on the row.
let items: Vec<ListItem> = app
.projects
.creatable_projects()
.iter()
.map(|p| ListItem::new(p.name.clone()))
.map(|p| ListItem::new(format!("{:>2} {}", p.weight, p.name)))
.collect();
let count = items.len();
let height = items.len() as u16 + 2;
let area = popup_area(frame, 44, height.max(3));
let area = popup_area(frame, 48, height.max(3));
let mut state = ListState::default().with_selected(Some(*sel));
let title = match flow {
crate::app::CreateFlow::Quick => "Project to propose a task in",
Expand Down Expand Up @@ -2414,6 +2417,38 @@ mod tests {
assert!(text.contains("nothing to do — press n"), "{text}");
}

/// The picker carries the weight it sorts on, so weightiest-first reads as
/// an order rather than a shuffle, and an archived project — which could
/// not take the task anyway (DESIGN.md §5) — is not on it at all.
#[test]
fn the_project_picker_shows_weights_and_hides_the_archived() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;

let mut app = app_with_status("", 0, 0);
let heavy = app.store.create_project("zeta", "/tmp/zeta").unwrap();
app.store.set_weight(heavy.id, 4).unwrap();
let retired = app.store.create_project("retired", "/tmp/retired").unwrap();
app.store.set_weight(retired.id, 5).unwrap();
app.store.set_archived(retired.id, true).unwrap();
app.refresh().unwrap();
app.mode = crate::app::Mode::PickProject {
sel: 0,
flow: crate::app::CreateFlow::Quick,
};

let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap();
terminal
.draw(|f| {
draw(f, &app);
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("4 zeta"), "{text}");
assert!(text.contains("3 voro"), "{text}");
assert!(!text.contains("retired"), "{text}");
}

/// The key map may not advertise a jump the gate would refuse (DESIGN.md
/// §9): with no project registered the Screens section drops `alt-1` and
/// `alt-2`, and gets them back the moment one exists.
Expand Down
Loading