513 lines
16 KiB
Rust
513 lines
16 KiB
Rust
//! Who (people intelligence) screen state.
|
|
//!
|
|
//! Manages 5 query modes (Expert, Workload, Reviews, Active, Overlap),
|
|
//! input fields (path or username depending on mode), and result display.
|
|
|
|
use lore::core::who_types::WhoResult;
|
|
|
|
use crate::text_width::{next_char_boundary, prev_char_boundary};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WhoMode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The 5 query modes for the who screen.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum WhoMode {
|
|
/// File-path expertise scores.
|
|
#[default]
|
|
Expert,
|
|
/// Issue/MR assignment workload for a username.
|
|
Workload,
|
|
/// Review activity breakdown for a username.
|
|
Reviews,
|
|
/// Recent unresolved discussions (no input needed).
|
|
Active,
|
|
/// Shared file knowledge between contributors.
|
|
Overlap,
|
|
}
|
|
|
|
impl WhoMode {
|
|
/// Short label for mode tab rendering.
|
|
#[must_use]
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Expert => "Expert",
|
|
Self::Workload => "Workload",
|
|
Self::Reviews => "Reviews",
|
|
Self::Active => "Active",
|
|
Self::Overlap => "Overlap",
|
|
}
|
|
}
|
|
|
|
/// Abbreviated 3-char label for narrow terminals.
|
|
#[must_use]
|
|
pub fn short_label(self) -> &'static str {
|
|
match self {
|
|
Self::Expert => "Exp",
|
|
Self::Workload => "Wkl",
|
|
Self::Reviews => "Rev",
|
|
Self::Active => "Act",
|
|
Self::Overlap => "Ovl",
|
|
}
|
|
}
|
|
|
|
/// Whether this mode requires a path input.
|
|
#[must_use]
|
|
pub fn needs_path(self) -> bool {
|
|
matches!(self, Self::Expert | Self::Overlap)
|
|
}
|
|
|
|
/// Whether this mode requires a username input.
|
|
#[must_use]
|
|
pub fn needs_username(self) -> bool {
|
|
matches!(self, Self::Workload | Self::Reviews)
|
|
}
|
|
|
|
/// Whether include_closed affects this mode's query.
|
|
#[must_use]
|
|
pub fn affected_by_include_closed(self) -> bool {
|
|
matches!(self, Self::Workload | Self::Active)
|
|
}
|
|
|
|
/// Cycle to the next mode (wraps around).
|
|
#[must_use]
|
|
pub fn next(self) -> Self {
|
|
match self {
|
|
Self::Expert => Self::Workload,
|
|
Self::Workload => Self::Reviews,
|
|
Self::Reviews => Self::Active,
|
|
Self::Active => Self::Overlap,
|
|
Self::Overlap => Self::Expert,
|
|
}
|
|
}
|
|
|
|
/// All modes in order.
|
|
pub const ALL: [Self; 5] = [
|
|
Self::Expert,
|
|
Self::Workload,
|
|
Self::Reviews,
|
|
Self::Active,
|
|
Self::Overlap,
|
|
];
|
|
|
|
/// Mode from 1-based number key (1=Expert, 2=Workload, ..., 5=Overlap).
|
|
#[must_use]
|
|
pub fn from_number(n: u8) -> Option<Self> {
|
|
match n {
|
|
1 => Some(Self::Expert),
|
|
2 => Some(Self::Workload),
|
|
3 => Some(Self::Reviews),
|
|
4 => Some(Self::Active),
|
|
5 => Some(Self::Overlap),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WhoState
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// State for the who/people screen.
|
|
#[derive(Debug, Default)]
|
|
pub struct WhoState {
|
|
/// Active query mode.
|
|
pub mode: WhoMode,
|
|
/// Current result (if any).
|
|
pub result: Option<WhoResult>,
|
|
|
|
// Input fields.
|
|
/// Path input text (used by Expert and Overlap modes).
|
|
pub path: String,
|
|
/// Cursor position within path string (byte offset).
|
|
pub path_cursor: usize,
|
|
/// Whether the path input has focus.
|
|
pub path_focused: bool,
|
|
|
|
/// Username input text (used by Workload and Reviews modes).
|
|
pub username: String,
|
|
/// Cursor position within username string (byte offset).
|
|
pub username_cursor: usize,
|
|
/// Whether the username input has focus.
|
|
pub username_focused: bool,
|
|
|
|
/// Toggle: include closed entities in Workload/Active queries.
|
|
pub include_closed: bool,
|
|
|
|
// Result navigation.
|
|
/// Index of the selected row in the result list.
|
|
pub selected_index: usize,
|
|
/// Vertical scroll offset for the result area.
|
|
pub scroll_offset: usize,
|
|
|
|
// Async coordination.
|
|
/// Monotonic generation counter for stale-response detection.
|
|
pub generation: u64,
|
|
/// Whether a query is in-flight.
|
|
pub loading: bool,
|
|
}
|
|
|
|
impl WhoState {
|
|
/// Enter the who screen: focus the appropriate input.
|
|
pub fn enter(&mut self) {
|
|
self.focus_input_for_mode();
|
|
}
|
|
|
|
/// Leave the who screen: blur all inputs.
|
|
pub fn leave(&mut self) {
|
|
self.path_focused = false;
|
|
self.username_focused = false;
|
|
}
|
|
|
|
/// Switch to a different mode. Clears result and resets selection.
|
|
/// Returns the new generation for stale detection.
|
|
pub fn set_mode(&mut self, mode: WhoMode) -> u64 {
|
|
if self.mode == mode {
|
|
return self.generation;
|
|
}
|
|
self.mode = mode;
|
|
self.result = None;
|
|
self.selected_index = 0;
|
|
self.scroll_offset = 0;
|
|
self.focus_input_for_mode();
|
|
self.bump_generation()
|
|
}
|
|
|
|
/// Toggle include_closed. Returns new generation if the mode is affected.
|
|
pub fn toggle_include_closed(&mut self) -> Option<u64> {
|
|
self.include_closed = !self.include_closed;
|
|
if self.mode.affected_by_include_closed() {
|
|
Some(self.bump_generation())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Apply query results if generation matches.
|
|
pub fn apply_results(&mut self, generation: u64, result: WhoResult) {
|
|
if generation != self.generation {
|
|
return; // Stale response — discard.
|
|
}
|
|
self.result = Some(result);
|
|
self.loading = false;
|
|
self.selected_index = 0;
|
|
self.scroll_offset = 0;
|
|
}
|
|
|
|
/// Submit the current input (trigger a query).
|
|
/// Returns the generation for the new query.
|
|
pub fn submit(&mut self) -> u64 {
|
|
self.loading = true;
|
|
self.bump_generation()
|
|
}
|
|
|
|
// --- Input field operations ---
|
|
|
|
/// Insert a char at cursor in the active input field.
|
|
pub fn insert_char(&mut self, c: char) {
|
|
if self.path_focused {
|
|
self.path.insert(self.path_cursor, c);
|
|
self.path_cursor += c.len_utf8();
|
|
} else if self.username_focused {
|
|
self.username.insert(self.username_cursor, c);
|
|
self.username_cursor += c.len_utf8();
|
|
}
|
|
}
|
|
|
|
/// Delete the char before cursor in the active input field.
|
|
pub fn delete_char_before_cursor(&mut self) {
|
|
if self.path_focused && self.path_cursor > 0 {
|
|
let prev = prev_char_boundary(&self.path, self.path_cursor);
|
|
self.path.drain(prev..self.path_cursor);
|
|
self.path_cursor = prev;
|
|
} else if self.username_focused && self.username_cursor > 0 {
|
|
let prev = prev_char_boundary(&self.username, self.username_cursor);
|
|
self.username.drain(prev..self.username_cursor);
|
|
self.username_cursor = prev;
|
|
}
|
|
}
|
|
|
|
/// Move cursor left in the active input.
|
|
pub fn cursor_left(&mut self) {
|
|
if self.path_focused && self.path_cursor > 0 {
|
|
self.path_cursor = prev_char_boundary(&self.path, self.path_cursor);
|
|
} else if self.username_focused && self.username_cursor > 0 {
|
|
self.username_cursor = prev_char_boundary(&self.username, self.username_cursor);
|
|
}
|
|
}
|
|
|
|
/// Move cursor right in the active input.
|
|
pub fn cursor_right(&mut self) {
|
|
if self.path_focused && self.path_cursor < self.path.len() {
|
|
self.path_cursor = next_char_boundary(&self.path, self.path_cursor);
|
|
} else if self.username_focused && self.username_cursor < self.username.len() {
|
|
self.username_cursor = next_char_boundary(&self.username, self.username_cursor);
|
|
}
|
|
}
|
|
|
|
/// Whether any input field has focus.
|
|
#[must_use]
|
|
pub fn has_text_focus(&self) -> bool {
|
|
self.path_focused || self.username_focused
|
|
}
|
|
|
|
/// Blur all inputs.
|
|
pub fn blur(&mut self) {
|
|
self.path_focused = false;
|
|
self.username_focused = false;
|
|
}
|
|
|
|
/// Focus the appropriate input for the current mode.
|
|
pub fn focus_input_for_mode(&mut self) {
|
|
self.path_focused = self.mode.needs_path();
|
|
self.username_focused = self.mode.needs_username();
|
|
// Place cursor at end of text.
|
|
if self.path_focused {
|
|
self.path_cursor = self.path.len();
|
|
}
|
|
if self.username_focused {
|
|
self.username_cursor = self.username.len();
|
|
}
|
|
}
|
|
|
|
// --- Selection navigation ---
|
|
|
|
/// Move selection up.
|
|
pub fn select_prev(&mut self) {
|
|
self.selected_index = self.selected_index.saturating_sub(1);
|
|
}
|
|
|
|
/// Move selection down (bounded by result count).
|
|
pub fn select_next(&mut self, result_count: usize) {
|
|
if result_count > 0 {
|
|
self.selected_index = (self.selected_index + 1).min(result_count - 1);
|
|
}
|
|
}
|
|
|
|
/// Ensure the selected row is visible within the viewport.
|
|
pub fn ensure_visible(&mut self, viewport_height: usize) {
|
|
if viewport_height == 0 {
|
|
return;
|
|
}
|
|
if self.selected_index < self.scroll_offset {
|
|
self.scroll_offset = self.selected_index;
|
|
} else if self.selected_index >= self.scroll_offset + viewport_height {
|
|
self.scroll_offset = self.selected_index - viewport_height + 1;
|
|
}
|
|
}
|
|
|
|
// --- Internal ---
|
|
|
|
fn bump_generation(&mut self) -> u64 {
|
|
self.generation += 1;
|
|
self.generation
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_who_mode_defaults_to_expert() {
|
|
assert_eq!(WhoMode::default(), WhoMode::Expert);
|
|
}
|
|
|
|
#[test]
|
|
fn test_who_mode_labels() {
|
|
assert_eq!(WhoMode::Expert.label(), "Expert");
|
|
assert_eq!(WhoMode::Active.label(), "Active");
|
|
assert_eq!(WhoMode::Overlap.label(), "Overlap");
|
|
}
|
|
|
|
#[test]
|
|
fn test_who_mode_needs_path() {
|
|
assert!(WhoMode::Expert.needs_path());
|
|
assert!(WhoMode::Overlap.needs_path());
|
|
assert!(!WhoMode::Workload.needs_path());
|
|
assert!(!WhoMode::Reviews.needs_path());
|
|
assert!(!WhoMode::Active.needs_path());
|
|
}
|
|
|
|
#[test]
|
|
fn test_who_mode_needs_username() {
|
|
assert!(WhoMode::Workload.needs_username());
|
|
assert!(WhoMode::Reviews.needs_username());
|
|
assert!(!WhoMode::Expert.needs_username());
|
|
assert!(!WhoMode::Active.needs_username());
|
|
}
|
|
|
|
#[test]
|
|
fn test_who_mode_next_cycles() {
|
|
let start = WhoMode::Expert;
|
|
let m = start.next().next().next().next().next();
|
|
assert_eq!(m, start);
|
|
}
|
|
|
|
#[test]
|
|
fn test_who_mode_from_number() {
|
|
assert_eq!(WhoMode::from_number(1), Some(WhoMode::Expert));
|
|
assert_eq!(WhoMode::from_number(5), Some(WhoMode::Overlap));
|
|
assert_eq!(WhoMode::from_number(0), None);
|
|
assert_eq!(WhoMode::from_number(6), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_who_state_default() {
|
|
let state = WhoState::default();
|
|
assert_eq!(state.mode, WhoMode::Expert);
|
|
assert!(state.result.is_none());
|
|
assert!(!state.include_closed);
|
|
assert_eq!(state.generation, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_mode_bumps_generation() {
|
|
let mut state = WhoState::default();
|
|
let generation = state.set_mode(WhoMode::Workload);
|
|
assert_eq!(generation, 1);
|
|
assert_eq!(state.mode, WhoMode::Workload);
|
|
assert!(state.result.is_none());
|
|
assert!(state.username_focused);
|
|
assert!(!state.path_focused);
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_mode_same_does_not_bump() {
|
|
let mut state = WhoState::default();
|
|
let generation = state.set_mode(WhoMode::Expert);
|
|
assert_eq!(generation, 0); // No bump for same mode.
|
|
}
|
|
|
|
#[test]
|
|
fn test_toggle_include_closed_returns_gen_for_affected_modes() {
|
|
let state = &mut WhoState {
|
|
mode: WhoMode::Workload,
|
|
..WhoState::default()
|
|
};
|
|
let generation = state.toggle_include_closed();
|
|
assert!(generation.is_some());
|
|
assert!(state.include_closed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_toggle_include_closed_returns_none_for_unaffected_modes() {
|
|
let state = &mut WhoState {
|
|
mode: WhoMode::Expert,
|
|
..WhoState::default()
|
|
};
|
|
let generation = state.toggle_include_closed();
|
|
assert!(generation.is_none());
|
|
assert!(state.include_closed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stale_response_guard() {
|
|
let mut state = WhoState::default();
|
|
let stale_gen = state.submit();
|
|
// Bump generation again (simulating user changed mode).
|
|
let _new_gen = state.set_mode(WhoMode::Active);
|
|
// Old response arrives — should be discarded.
|
|
state.apply_results(
|
|
stale_gen,
|
|
WhoResult::Active(lore::core::who_types::ActiveResult {
|
|
discussions: vec![],
|
|
total_unresolved_in_window: 0,
|
|
truncated: false,
|
|
}),
|
|
);
|
|
assert!(state.result.is_none()); // Stale, discarded.
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_and_delete_char() {
|
|
let mut state = WhoState {
|
|
path_focused: true,
|
|
..WhoState::default()
|
|
};
|
|
state.insert_char('s');
|
|
state.insert_char('r');
|
|
state.insert_char('c');
|
|
assert_eq!(state.path, "src");
|
|
assert_eq!(state.path_cursor, 3);
|
|
|
|
state.delete_char_before_cursor();
|
|
assert_eq!(state.path, "sr");
|
|
assert_eq!(state.path_cursor, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cursor_movement() {
|
|
let mut state = WhoState {
|
|
username_focused: true,
|
|
username: "alice".into(),
|
|
username_cursor: 5,
|
|
..WhoState::default()
|
|
};
|
|
|
|
state.cursor_left();
|
|
assert_eq!(state.username_cursor, 4);
|
|
state.cursor_right();
|
|
assert_eq!(state.username_cursor, 5);
|
|
// Right at end is clamped.
|
|
state.cursor_right();
|
|
assert_eq!(state.username_cursor, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_prev_next() {
|
|
let mut state = WhoState::default();
|
|
state.select_next(5);
|
|
assert_eq!(state.selected_index, 1);
|
|
state.select_next(5);
|
|
assert_eq!(state.selected_index, 2);
|
|
state.select_prev();
|
|
assert_eq!(state.selected_index, 1);
|
|
state.select_prev();
|
|
assert_eq!(state.selected_index, 0);
|
|
state.select_prev(); // Should not underflow.
|
|
assert_eq!(state.selected_index, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_visible() {
|
|
let mut state = WhoState {
|
|
selected_index: 15,
|
|
..WhoState::default()
|
|
};
|
|
state.ensure_visible(5);
|
|
assert_eq!(state.scroll_offset, 11); // 15 - 5 + 1
|
|
}
|
|
|
|
#[test]
|
|
fn test_enter_focuses_correct_input() {
|
|
let mut state = WhoState {
|
|
mode: WhoMode::Expert,
|
|
..WhoState::default()
|
|
};
|
|
state.enter();
|
|
assert!(state.path_focused);
|
|
assert!(!state.username_focused);
|
|
|
|
state.mode = WhoMode::Reviews;
|
|
state.enter();
|
|
assert!(!state.path_focused);
|
|
// Reviews needs username.
|
|
// focus_input_for_mode is called in enter().
|
|
}
|
|
|
|
#[test]
|
|
fn test_affected_by_include_closed() {
|
|
assert!(WhoMode::Workload.affected_by_include_closed());
|
|
assert!(WhoMode::Active.affected_by_include_closed());
|
|
assert!(!WhoMode::Expert.affected_by_include_closed());
|
|
assert!(!WhoMode::Reviews.affected_by_include_closed());
|
|
assert!(!WhoMode::Overlap.affected_by_include_closed());
|
|
}
|
|
}
|